← Back to task

Commit 1624e439

commit 1624e4397f953711330af15fb30989b35d34a11b
Author: Ben Sima <ben@bensima.com>
Date:   Mon Dec 1 04:15:38 2025

    Add jr task log CLI command
    
    Perfect! Both output modes work correctly. The task has been
    successfull
    
    1. ✅ Basic log viewing: `jr task log <id>` 2. ✅ Session-specific
    viewing: `jr task log <id> --session=<sid>` 3. ✅ Follow mode:
    `jr task log <id> --follow` (polls every 500ms) 4. ✅ JSON output:
    `jr task log <id> --json` 5. ✅ Human-readable formatting with
    timestamps 6. ✅ Proper event formatting for Assistant, ToolCall,
    ToolResult, Cost, 7. ✅ All tests pass 8. ✅ No lint or hlint issues
    
    The implementation was mostly complete when I started - I only
    needed to
    
    Task-Id: t-197.6

diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index 2be8ea1d..d5283651 100644
--- a/Omni/Jr/Web.hs
+++ b/Omni/Jr/Web.hs
@@ -19,6 +19,7 @@ where
 import Alpha
 import qualified Control.Concurrent as Concurrent
 import qualified Data.Aeson as Aeson
+import qualified Data.ByteString.Lazy as LBS
 import qualified Data.List as List
 import qualified Data.Text as Text
 import qualified Data.Text.Lazy as LazyText
@@ -261,7 +262,7 @@ instance Accept SSE where
   contentType _ = "text/event-stream"
 
 instance MimeRender SSE ByteString where
-  mimeRender _ = identity
+  mimeRender _ = LBS.fromStrict
 
 data HomePage = HomePage TaskCore.TaskStats [TaskCore.Task] [TaskCore.Task] Bool TaskCore.AggregatedMetrics TimeRange UTCTime
 
@@ -2576,7 +2577,7 @@ streamAgentEvents tid sid = do
 streamEventsStep :: Text -> Text -> Int -> [ByteString] -> Bool -> Source.StepT IO ByteString
 streamEventsStep tid sid lastId buffer sendExisting = case (sendExisting, buffer) of
   -- Send buffered existing events first
-  (True, b : bs) -> pure <| Source.Yield b (streamEventsStep tid sid lastId bs True)
+  (True, b : bs) -> Source.Yield b (streamEventsStep tid sid lastId bs True)
   (True, []) -> streamEventsStep tid sid lastId [] False
   -- Poll for new events
   (False, _) ->
diff --git a/Omni/Task.hs b/Omni/Task.hs
index c6e68ac9..11d080b8 100644
--- a/Omni/Task.hs
+++ b/Omni/Task.hs
@@ -8,8 +8,11 @@ module Omni.Task where
 
 import Alpha
 import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.KeyMap as KM
 import qualified Data.ByteString.Lazy.Char8 as BLC
 import qualified Data.Text as T
+import qualified Data.Text.Encoding as TE
+import Data.Time (defaultTimeLocale, formatTime)
 import qualified Omni.Cli as Cli
 import qualified Omni.Namespace as Namespace
 import Omni.Task.Core
@@ -54,6 +57,7 @@ Usage:
   task tree [<id>] [--json]
   task progress <id> [--json]
   task stats [--epic=<id>] [--json]
+  task log <id> [--session=<sid>] [--follow] [--json]
   task export [-o <file>]
   task import -i <file>
   task test
@@ -73,6 +77,7 @@ Commands:
   tree                          Show task tree (epics with children, or all epics if no ID given)
   progress                      Show progress for an epic
   stats                         Show task statistics
+  log                           Show agent event log for a task
   export                        Export tasks to JSONL
   import                        Import tasks from JSONL file
   test                          Run tests
@@ -96,6 +101,8 @@ Options:
   --json                        Output in JSON format (for agent use)
   --quiet                       Non-interactive mode (for agents)
   --verified                    Mark task as verified (code compiles, tests pass, feature works)
+  --session=<sid>               Show events for specific session ID
+  --follow                      Stream events in real-time (like tail -f)
   -i <file>                     Input file for import
   -o <file>                     Output file for export
 
@@ -413,6 +420,13 @@ move' args
           stats <- getTaskStats maybeEpic
           outputJson stats
         else showTaskStats maybeEpic
+  | args `Cli.has` Cli.command "log" = do
+      tid <- getArgText args "id"
+      let maybeSession = T.pack </ Cli.getArg args (Cli.longOption "session")
+          followMode = args `Cli.has` Cli.longOption "follow"
+      if followMode
+        then followTaskLog tid maybeSession
+        else showTaskLog tid maybeSession (isJsonMode args)
   | args `Cli.has` Cli.command "export" = do
       file <- case Cli.getArg args (Cli.shortOption 'o') of
         Nothing -> pure Nothing
@@ -437,6 +451,143 @@ move' args
         Nothing -> panic (T.pack name <> " required")
         Just val -> pure (T.pack val)
 
+-- | Show task log for a given task ID and optional session
+showTaskLog :: Text -> Maybe Text -> Bool -> IO ()
+showTaskLog tid maybeSession jsonMode = do
+  events <- case maybeSession of
+    Just sid -> getEventsForSession sid
+    Nothing -> getEventsForTask tid
+
+  when (null events && not jsonMode) <| do
+    putText "No events found for this task."
+
+  if jsonMode
+    then outputJson events
+    else traverse_ printEvent events
+
+-- | Follow task log in real-time (poll for new events)
+followTaskLog :: Text -> Maybe Text -> IO ()
+followTaskLog tid maybeSession = do
+  -- Get session ID (use provided or get latest)
+  sid <- getSid
+
+  -- Print initial events
+  events <- getEventsForSession sid
+  traverse_ printEvent events
+
+  -- Start polling for new events
+  let lastEventId = if null events then 0 else maximum (map storedEventId events)
+  pollEvents sid lastEventId
+  where
+    getSid = case maybeSession of
+      Just s -> pure s
+      Nothing -> do
+        maybeSid <- getLatestSessionForTask tid
+        case maybeSid of
+          Nothing -> do
+            putText "No session found for this task. Waiting for events..."
+            threadDelay 1000000
+            getSid -- Recursively retry
+          Just s -> pure s
+
+    pollEvents sid lastId = do
+      threadDelay 500000 -- Poll every 500ms
+      newEvents <- getEventsSince sid lastId
+      unless (null newEvents) <| do
+        traverse_ printEvent newEvents
+      let newLastId = if null newEvents then lastId else maximum (map storedEventId newEvents)
+      pollEvents sid newLastId
+
+-- | Print a single event in human-readable format
+printEvent :: StoredEvent -> IO ()
+printEvent event = do
+  let timestamp = storedEventTimestamp event
+      eventType = storedEventType event
+      content = storedEventContent event
+
+  -- Format timestamp as HH:MM:SS
+  let timeStr = T.pack <| formatTime defaultTimeLocale "%H:%M:%S" timestamp
+
+  -- Parse and format the content based on event type
+  let formatted = case eventType of
+        "Assistant" -> formatAssistant content
+        "ToolCall" -> formatToolCall content
+        "ToolResult" -> formatToolResult content
+        "Cost" -> formatCost content
+        "Error" -> formatError content
+        "Complete" -> "Complete"
+        _ -> eventType <> ": " <> content
+
+  putText ("[" <> timeStr <> "] " <> formatted)
+
+-- Format Assistant messages
+formatAssistant :: Text -> Text
+formatAssistant content =
+  case Aeson.decode (BLC.pack <| T.unpack content) of
+    Just (Aeson.String msg) -> "Assistant: " <> truncateText 200 msg
+    _ -> "Assistant: " <> truncateText 200 content
+
+-- Format ToolCall events
+formatToolCall :: Text -> Text
+formatToolCall content =
+  case Aeson.decode (BLC.pack <| T.unpack content) of
+    Just (Aeson.String msg) -> "Tool: " <> msg
+    Just (Aeson.Object obj) ->
+      let toolName = case KM.lookup "tool" obj of
+            Just (Aeson.String n) -> n
+            _ -> "<unknown>"
+          args = case KM.lookup "args" obj of
+            Just val -> " " <> TE.decodeUtf8 (BLC.toStrict (Aeson.encode val))
+            _ -> ""
+       in "Tool: " <> toolName <> args
+    _ -> "Tool: " <> truncateText 100 content
+
+-- Format ToolResult events
+formatToolResult :: Text -> Text
+formatToolResult content =
+  case Aeson.decode (BLC.pack <| T.unpack content) of
+    Just (Aeson.Object obj) ->
+      let toolName = case KM.lookup "tool" obj of
+            Just (Aeson.String n) -> n
+            _ -> "<unknown>"
+          success = case KM.lookup "success" obj of
+            Just (Aeson.Bool True) -> "ok"
+            Just (Aeson.Bool False) -> "failed"
+            _ -> "?"
+          output = case KM.lookup "output" obj of
+            Just (Aeson.String s) -> " (" <> tshow (T.length s) <> " bytes)"
+            _ -> ""
+       in "Result: " <> toolName <> " (" <> success <> ")" <> output
+    _ -> "Result: " <> truncateText 100 content
+
+-- Format Cost events
+formatCost :: Text -> Text
+formatCost content =
+  case Aeson.decode (BLC.pack <| T.unpack content) of
+    Just (Aeson.Object obj) ->
+      let tokens = case KM.lookup "tokens" obj of
+            Just (Aeson.Number n) -> tshow (round n :: Int)
+            _ -> "?"
+          cents = case KM.lookup "cents" obj of
+            Just (Aeson.Number n) -> tshow (round n :: Int)
+            _ -> "?"
+       in "Cost: " <> tokens <> " tokens, " <> cents <> " cents"
+    _ -> "Cost: " <> content
+
+-- Format Error events
+formatError :: Text -> Text
+formatError content =
+  case Aeson.decode (BLC.pack <| T.unpack content) of
+    Just (Aeson.String msg) -> "Error: " <> msg
+    _ -> "Error: " <> content
+
+-- Truncate text to a maximum length
+truncateText :: Int -> Text -> Text
+truncateText maxLen txt =
+  if T.length txt > maxLen
+    then T.take maxLen txt <> "..."
+    else txt
+
 test :: Test.Tree
 test =
   Test.group
@@ -1010,5 +1161,34 @@ cliTests =
           Left err -> Test.assertFailure <| "Failed to parse 'comment --json': " <> show err
           Right args -> do
             args `Cli.has` Cli.command "comment" Test.@?= True
+            args `Cli.has` Cli.longOption "json" Test.@?= True,
+      Test.unit "log command" <| do
+        let result = Docopt.parseArgs help ["log", "t-123"]
+        case result of
+          Left err -> Test.assertFailure <| "Failed to parse 'log': " <> show err
+          Right args -> do
+            args `Cli.has` Cli.command "log" Test.@?= True
+            Cli.getArg args (Cli.argument "id") Test.@?= Just "t-123",
+      Test.unit "log command with --session flag" <| do
+        let result = Docopt.parseArgs help ["log", "t-123", "--session=s-456"]
+        case result of
+          Left err -> Test.assertFailure <| "Failed to parse 'log --session': " <> show err
+          Right args -> do
+            args `Cli.has` Cli.command "log" Test.@?= True
+            Cli.getArg args (Cli.argument "id") Test.@?= Just "t-123"
+            Cli.getArg args (Cli.longOption "session") Test.@?= Just "s-456",
+      Test.unit "log command with --follow flag" <| do
+        let result = Docopt.parseArgs help ["log", "t-123", "--follow"]
+        case result of
+          Left err -> Test.assertFailure <| "Failed to parse 'log --follow': " <> show err
+          Right args -> do
+            args `Cli.has` Cli.command "log" Test.@?= True
+            args `Cli.has` Cli.longOption "follow" Test.@?= True,
+      Test.unit "log command with --json flag" <| do
+        let result = Docopt.parseArgs help ["log", "t-123", "--json"]
+        case result of
+          Left err -> Test.assertFailure <| "Failed to parse 'log --json': " <> show err
+          Right args -> do
+            args `Cli.has` Cli.command "log" Test.@?= True
             args `Cli.has` Cli.longOption "json" Test.@?= True
     ]