← Back to task

Commit 03a74a8e

commit 03a74a8ead6b7fc4133e09f170ca0861fb7b48bd
Author: Coder Agent <coder@agents.omni>
Date:   Thu Apr 16 03:47:16 2026

    feat(agentd): normalize persistent session JSONL result events
    
    Emit final agent JSON results with explicit type and timestamp.
    
    Normalize legacy untyped result lines in persistent readers.
    
    Improve watch formatting for normalized result events.
    
    Task-Id: t-803

diff --git a/Omni/Agent.hs b/Omni/Agent.hs
index bd612d06..817dacd0 100755
--- a/Omni/Agent.hs
+++ b/Omni/Agent.hs
@@ -390,7 +390,8 @@ emitRunResult :: AgentOptions -> Bool -> AgentResult -> IO ()
 emitRunResult opts exitOnError result =
   if optJson opts
     then do
-      BL.putStr <| Aeson.encode result
+      now <- Time.getCurrentTime
+      BL.putStr <| Aeson.encode (jsonResultEvent now result)
       TextIO.putStrLn "" -- newline to make it valid JSONL
     else case result of
       AgentSuccess response -> TextIO.putStrLn response
@@ -398,6 +399,34 @@ emitRunResult opts exitOnError result =
         TextIO.hPutStrLn IO.stderr <| "Error: " <> err
         when exitOnError <| Exit.exitWith (Exit.ExitFailure 1)
 
+jsonResultEvent :: Time.UTCTime -> AgentResult -> Aeson.Value
+jsonResultEvent now result =
+  let base =
+        case result of
+          AgentSuccess response ->
+            Aeson.object
+              [ "success" Aeson..= True,
+                "response" Aeson..= response
+              ]
+          AgentError err ->
+            Aeson.object
+              [ "success" Aeson..= False,
+                "error" Aeson..= err
+              ]
+   in case base of
+        Aeson.Object obj ->
+          Aeson.Object
+            <| KeyMap.insert "timestamp" (Aeson.toJSON now)
+            <| KeyMap.insert "type" (Aeson.String "result")
+            <| obj
+        _ ->
+          Aeson.object
+            [ "type" Aeson..= ("result" :: Text),
+              "timestamp" Aeson..= now,
+              "success" Aeson..= False,
+              "error" Aeson..= ("failed to encode result" :: Text)
+            ]
+
 -- | Load AGENTS.md or CLAUDE.md from cwd and all parent directories.
 -- Returns list of (path, content) pairs, with topmost parent first.
 loadProjectContextFiles :: FilePath -> IO [(FilePath, Text)]
diff --git a/Omni/Agentd.hs b/Omni/Agentd.hs
index 53992bc4..d579c818 100755
--- a/Omni/Agentd.hs
+++ b/Omni/Agentd.hs
@@ -1507,6 +1507,16 @@ renderPersistentWatchEvent details obj =
        in "infer_end" <> iter <> tokens <> duration <> cost <> responsePreview
     Just "checkpoint" ->
       "checkpoint " <> fromMaybe "?" (topLookupTextField "name" obj)
+    Just "result" ->
+      let ok = fromMaybe True (topLookupBoolField "success" obj)
+          prefix = if ok then "result ✓" else "result ✗"
+          message =
+            topLookupTextField "response" obj
+              <|> topLookupTextField "error" obj
+              <|> topLookupTextField "message" obj
+       in case message of
+            Nothing -> prefix
+            Just txt -> prefix <> ": " <> clipWatchText (if details then 220 else 120) txt
     Just "custom" ->
       renderPersistentWatchCustom details obj
     Just other ->
@@ -3249,6 +3259,16 @@ persistentLineLabel line = do
           let toolName = fromMaybe "tool" (topLookupTextField "toolName" msgObj)
            in Just <| "tool result: " <> toolName
         _ -> Just <| "message: " <> role
+    "result" ->
+      let ok = fromMaybe True (topLookupBoolField "success" obj)
+          prefix = if ok then "result" else "result error"
+          detail =
+            topLookupTextField "response" obj
+              <|> topLookupTextField "error" obj
+              <|> topLookupTextField "message" obj
+       in Just <| case detail of
+            Nothing -> prefix
+            Just txt -> prefix <> ": " <> clipActivity txt
     _ -> Just eventType
 
 persistentToolLabel :: Text -> Maybe Text
@@ -3290,11 +3310,20 @@ messageTextSnippet msgObj =
       topLookupTextField "text" partObj
     textPart _ = Nothing
 
+normalizePersistentSessionObject :: Aeson.Object -> Aeson.Object
+normalizePersistentSessionObject obj =
+  case topLookupTextField "type" obj of
+    Just _ -> obj
+    Nothing ->
+      case topLookupBoolField "success" obj of
+        Just _ -> KeyMap.insert "type" (Aeson.String "result") obj
+        Nothing -> obj
+
 decodeJsonObject :: Text -> Maybe Aeson.Object
 decodeJsonObject line = do
   value <- Aeson.decodeStrict' (TE.encodeUtf8 line)
   case value of
-    Aeson.Object obj -> Just obj
+    Aeson.Object obj -> Just (normalizePersistentSessionObject obj)
     _ -> Nothing
 
 topLookupTextField :: Text -> Aeson.Object -> Maybe Text
@@ -3750,5 +3779,12 @@ test =
         case formatPersistentWatchLine tz False "agent-x" line of
           Nothing -> Test.assertFailure "Expected formatted line"
           Just out ->
-            Test.assertBool "formatted tool call line" ("tool_call run_bash cmd=pwd && ls -la" `Text.isInfixOf` out)
+            Test.assertBool "formatted tool call line" ("tool_call run_bash cmd=pwd && ls -la" `Text.isInfixOf` out),
+      Test.unit "persistent watch normalizes legacy untyped result lines" <| do
+        let tz = Time.minutesToTimeZone 0
+            line = "{\"success\":true,\"response\":\"done\"}"
+        case formatPersistentWatchLine tz False "agent-x" line of
+          Nothing -> Test.assertFailure "Expected formatted line"
+          Just out ->
+            Test.assertBool "formatted legacy result line" ("result ✓: done" `Text.isInfixOf` out)
     ]
diff --git a/Omni/Agentd/Daemon.hs b/Omni/Agentd/Daemon.hs
index b2524c5e..6da2af2e 100644
--- a/Omni/Agentd/Daemon.hs
+++ b/Omni/Agentd/Daemon.hs
@@ -1054,10 +1054,7 @@ dropLeadingPartialLine chunk =
 
 sessionStatusSnapshotFromLine :: BS.ByteString -> Maybe SessionStatusSnapshot
 sessionStatusSnapshotFromLine lineBytes = do
-  value <- Aeson.decodeStrict' lineBytes
-  obj <- case value of
-    Aeson.Object o -> Just o
-    _ -> Nothing
+  obj <- sessionObjectFromJsonlBytes lineBytes
   (status, source) <- case lookupTextKey "type" obj of
     Just "custom" -> do
       customType <- lookupTextKey "custom_type" obj
@@ -1070,6 +1067,7 @@ sessionStatusSnapshotFromLine lineBytes = do
     Just "infer_end" -> Just (StatusRunning, "infer_end")
     Just "tool_call" -> Just (StatusRunning, "tool_call")
     Just "tool_result" -> Just (StatusRunning, "tool_result")
+    Just "result" -> Just (StatusIdle, "result")
     _ -> Nothing
   let timestamp = parseLogTimestamp =<< lookupTextKey "timestamp" obj
   pure SessionStatusSnapshot {sssStatus = status, sssSource = source, sssTimestamp = timestamp}
@@ -1675,13 +1673,25 @@ parseLogTimestamp ts =
         <|> Time.parseTimeM True Time.defaultTimeLocale "%Y-%m-%dT%H:%M:%S%Q%z" input
         <|> Time.parseTimeM True Time.defaultTimeLocale "%Y-%m-%d %H:%M:%S%Q UTC" input
 
-lineObjectFromJsonl :: Text -> Maybe Aeson.Object
-lineObjectFromJsonl line = do
-  value <- Aeson.decodeStrict' (TextEncoding.encodeUtf8 line)
+normalizeSessionObject :: Aeson.Object -> Aeson.Object
+normalizeSessionObject obj =
+  case lookupTextKey "type" obj of
+    Just _ -> obj
+    Nothing ->
+      case lookupBoolKey "success" obj of
+        Just _ -> KeyMap.insert "type" (Aeson.String "result") obj
+        Nothing -> obj
+
+sessionObjectFromJsonlBytes :: BS.ByteString -> Maybe Aeson.Object
+sessionObjectFromJsonlBytes bytes = do
+  value <- Aeson.decodeStrict' bytes
   case value of
-    Aeson.Object obj -> Just obj
+    Aeson.Object obj -> Just (normalizeSessionObject obj)
     _ -> Nothing
 
+lineObjectFromJsonl :: Text -> Maybe Aeson.Object
+lineObjectFromJsonl line = sessionObjectFromJsonlBytes (TextEncoding.encodeUtf8 line)
+
 lineMatchesLogFilter :: ParsedLogFilterOptions -> Text -> Bool
 lineMatchesLogFilter filterOpts line =
   let obj = lineObjectFromJsonl line
@@ -1952,8 +1962,7 @@ processSessionLine runId maildir state lineBytes =
 
 parseNotifyEventLine :: Text -> Maybe NotifyEvent
 parseNotifyEventLine line = do
-  value <- Aeson.decodeStrict' (TextEncoding.encodeUtf8 line)
-  obj <- asObject value
+  obj <- lineObjectFromJsonl line
   eventType <- lookupTextKey "type" obj
   guard (eventType == "custom")
   customType <- lookupTextKey "custom_type" obj
@@ -1982,9 +1991,6 @@ parseNotifyEventLine line = do
             neTimestamp = timestamp
           }
     _ -> Nothing
-  where
-    asObject (Aeson.Object obj) = Just obj
-    asObject _ = Nothing
 
 lookupTextKey :: Text -> Aeson.Object -> Maybe Text
 lookupTextKey key obj =
@@ -1992,6 +1998,12 @@ lookupTextKey key obj =
     Just (Aeson.String val) -> Just val
     _ -> Nothing
 
+lookupBoolKey :: Text -> Aeson.Object -> Maybe Bool
+lookupBoolKey key obj =
+  case KeyMap.lookup (Key.fromText key) obj of
+    Just (Aeson.Bool val) -> Just val
+    _ -> Nothing
+
 lookupObjectKey :: Text -> Aeson.Object -> Maybe Aeson.Object
 lookupObjectKey key obj =
   case KeyMap.lookup (Key.fromText key) obj of
@@ -2798,6 +2810,18 @@ test =
             sssStatus snapshot Test.@=? StatusIdle
             sssSource snapshot Test.@=? "agent_complete"
             sssTimestamp snapshot Test.@=? parseLogTimestamp "2026-04-11T15:01:19Z",
+      Test.unit "session status parser handles legacy untyped result objects" <| do
+        let lines' =
+              [ "{\"type\":\"infer_end\",\"timestamp\":\"2026-04-11T15:01:15Z\"}",
+                "{\"success\":true,\"response\":\"Done\"}"
+              ]
+            parsed = inferSessionStatusFromLines (map TextEncoding.encodeUtf8 lines')
+        case parsed of
+          Nothing -> Test.assertFailure "Expected session status"
+          Just snapshot -> do
+            sssStatus snapshot Test.@=? StatusIdle
+            sssSource snapshot Test.@=? "result"
+            sssTimestamp snapshot Test.@=? Nothing,
       Test.unit "reconcile keeps fresher DB running status" <| do
         case (parseLogTimestamp "2026-04-11T15:01:30Z", parseLogTimestamp "2026-04-11T15:01:20Z", parseLogTimestamp "2026-04-11T15:01:19Z") of
           (Just nowTs, Just dbUpdatedAt, Just sessionAt) -> do
@@ -2928,6 +2952,14 @@ test =
         case compileLogFilterOptions opts of
           Left err -> Test.assertFailure (Text.unpack err)
           Right parsed -> selectLogLines parsed lines' Test.@=? [List.last lines'],
+      Test.unit "log filter supports legacy untyped result lines" <| do
+        let lines' =
+              [ "{\"type\":\"session\",\"timestamp\":\"2026-04-11T13:59:40.563Z\"}",
+                "{\"success\":true,\"response\":\"done\"}"
+              ]
+        case compileLogFilterOptions defaultLogFilterOptions {lfoType = Just "result"} of
+          Left err -> Test.assertFailure (Text.unpack err)
+          Right parsed -> selectLogLines parsed lines' Test.@=? [List.last lines'],
       Test.unit "log filter rejects invalid --since timestamps" <| do
         case compileLogFilterOptions defaultLogFilterOptions {lfoSince = Just "not-a-time"} of
           Left _ -> pure ()