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 ()