baikai 0.10.1.0 → 0.10.1.1
raw patch · 4 files changed
+84/−14 lines, 4 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
Files
- CHANGELOG.md +16/−0
- baikai.cabal +1/−1
- src/Baikai/Provider/Cli/Internal.hs +15/−12
- test/CliInternalSpec.hs +52/−1
CHANGELOG.md view
@@ -7,6 +7,22 @@ ## [Unreleased] +## [baikai-openai 0.7.3.1] - 2026-11-14++### Fixed++- Require `baikai ^>=0.10.1.1` in library and tests so new adapter consumers+ receive the Codex final-message correction.++## [baikai 0.10.1.1] - 2026-11-14++### Fixed++- `baikai`: the Codex batch parser returns the last completed agent message+ instead of concatenating complete messages, preserving structured JSON after+ intermediate commentary. Started and updated items cannot replace the final+ answer; usage and thread observations still cover the whole run ([BUG-2](docs/bug-reports/codex-concatenates-complete-agent-messages.md)).+ ## [baikai 0.10.1.0] - 2026-11-03 ### Added
baikai.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: baikai-version: 0.10.1.0+version: 0.10.1.1 synopsis: Unified Haskell interface for multiple AI providers description: baikai provides a unified, provider-agnostic Haskell interface for working
src/Baikai/Provider/Cli/Internal.hs view
@@ -205,7 +205,7 @@ -- must be recorded as 'Baikai.Evidence.Unobserved' downstream and must -- never be filled in from the request. data CodexRunReport = CodexRunReport- { -- | The concatenated text of every @agent_message@ event.+ { -- | The last completed @agent_message@ body, or empty text if none. message :: !Text, -- | Codex's own handle for the conversation, from the -- thread-start event.@@ -221,10 +221,10 @@ -- | The accumulator 'parseCodexJsonlStream' folds events into. ----- Separate from 'CodexRunReport' only because the message text arrives--- in pieces and is kept reversed until the fold finishes.+-- Complete message bodies replace the candidate, including an empty body.+-- They are independent answers rather than fragments to concatenate. data CodexAccumulator = CodexAccumulator- { messages :: ![Text],+ { lastMessage :: !(Maybe Text), threadId :: !(Maybe Text), reportedModel :: !(Maybe Text), usage :: !(Maybe Usage)@@ -234,7 +234,7 @@ emptyCodexAccumulator :: CodexAccumulator emptyCodexAccumulator = CodexAccumulator- { messages = [],+ { lastMessage = Nothing, threadId = Nothing, reportedModel = Nothing, usage = Nothing@@ -243,6 +243,8 @@ -- | Consume a stream of stdout bytes from @codex exec --json@, split on -- newlines, decode each line as JSON, and fold the events into what the -- run reported about itself.+-- Only the last completed agent message supplies the answer; observations+-- are collected independently from the entire stream. -- -- A line that is not valid JSON is skipped rather than failing the run: -- codex writes progress chatter to stderr, but a future version writing@@ -269,7 +271,7 @@ let acc = absorbLine folded (joinPieces pending) pure CodexRunReport- { message = Text.concat (reverse (acc ^. #messages)),+ { message = fromMaybe "" (acc ^. #lastMessage), threadId = acc ^. #threadId, reportedModel = acc ^. #reportedModel, usage = acc ^. #usage@@ -296,7 +298,7 @@ absorbCodexEvent acc v = withTurn ( acc- & #messages %~ maybe id (:) (extractAgentMessage v)+ & #lastMessage %~ (extractAgentMessage v <|>) & #threadId %~ (<|> extractThreadId v) ) where@@ -308,8 +310,8 @@ & #reportedModel .~ reported -- | Best-effort extractor for the assistant text inside a single--- Codex @--json@ event. See the original implementation's--- documentation for the schema variants accepted.+-- Codex @--json@ event. Modern @item@ envelopes must be @item.completed@;+-- older flat and @msg@-nested agent messages are also complete bodies. extractAgentMessage :: Value -> Maybe Text extractAgentMessage = parseMaybe parser where@@ -317,10 +319,11 @@ mItem <- o .:? "item" case (mItem :: Maybe Value) of Just (Aeson.Object io) -> do+ eventType <- o .:? "type" ty <- io .:? "type"- case (ty :: Maybe Text) of- Just "agent_message" -> pickText io- _ -> tryMsg o+ case (eventType :: Maybe Text, ty :: Maybe Text) of+ (Just "item.completed", Just "agent_message") -> pickText io+ _ -> fail "not a completed agent_message" _ -> tryMsg o tryMsg o = do
test/CliInternalSpec.hs view
@@ -16,6 +16,7 @@ import Data.ByteString (ByteString) import Data.ByteString qualified as BS import Data.ByteString.Char8 qualified as BS8+import Data.ByteString.Lazy qualified as LBS import Data.Generics.Labels () import Data.List (isInfixOf) import Data.Text qualified as Text@@ -80,7 +81,53 @@ codexParserTests = testGroup "codex exec --json event stream"- [ testCase "a recorded run yields its text, thread id, and token counts" $ do+ [ testCase "two complete JSON answers return only the final object" $ do+ let answer why = Aeson.object ["why" Aeson..= (why :: Text.Text), "what" Aeson..= ("X" :: Text.Text), "blocking" Aeson..= ("B" :: Text.Text), "status" Aeson..= ("S" :: Text.Text)]+ encoded = Text.decodeUtf8 . LBS.toStrict . Aeson.encode+ report <- parseCodex [codexMessage "item.completed" (encoded (answer "Intermediate")), codexMessage "item.completed" (encoded (answer "Final"))]+ Aeson.eitherDecodeStrict' (Text.encodeUtf8 (report ^. #message)) @?= Right (answer "Final"),+ testCase "three complete messages replace rather than append" $ do+ report <- parseCodex (map (codexMessage "item.completed") ["first", "second", "third"])+ report ^. #message @?= "third",+ testCase "prose commentary is excluded from a final JSON answer" $ do+ report <- parseCodex (map (codexMessage "item.completed") ["Working on it.", "{\"why\":\"Final\"}"])+ report ^. #message @?= "{\"why\":\"Final\"}",+ testCase "an empty completed message replaces preceding text" $ do+ report <- parseCodex (map (codexMessage "item.completed") ["commentary", ""])+ report ^. #message @?= "",+ testCase "malformed final answer text replaces earlier valid JSON" $ do+ report <- parseCodex (map (codexMessage "item.completed") ["{\"why\":\"valid\"}", "{\"why\":"])+ report ^. #message @?= "{\"why\":"+ assertBool "the selected body is still invalid JSON" (case Aeson.eitherDecodeStrict' @Aeson.Value (Text.encodeUtf8 (report ^. #message)) of Left _ -> True; Right _ -> False),+ testCase "started and updated items never replace a completed answer" $ do+ report <-+ parseCodex+ [ codexMessage "item.started" "before",+ codexMessage "item.completed" "first",+ codexMessage "item.updated" "between",+ codexMessage "item.completed" "final",+ codexMessage "item.started" "after-start",+ codexMessage "item.updated" "after-update",+ "{\"type\":\"item.completed\",\"item\":{\"type\":\"reasoning\",\"text\":\"reasoning\"}}\n",+ "{\"type\":\"item.completed\",\"item\":{\"type\":\"command_execution\",\"text\":\"tool\"}}\n"+ ]+ report ^. #message @?= "final",+ testCase "only incomplete items yield the existing no-answer result" $ do+ report <- parseCodex [codexMessage "item.started" "pending", codexMessage "item.updated" "partial"]+ report ^. #message @?= "",+ testCase "legacy complete messages also replace their predecessors" $ do+ nested <- parseCodex ["{\"msg\":{\"type\":\"agent_message\",\"message\":\"first\"}}\n{\"msg\":{\"type\":\"agent_message\",\"message\":\"nested-final\"}}\n"]+ nested ^. #message @?= "nested-final"+ flat <- parseCodex ["{\"type\":\"agent_message\",\"message\":\"first\"}\n{\"type\":\"agent_message\",\"text\":\"flat-final\"}\n"]+ flat ^. #message @?= "flat-final",+ testCase "message replacement survives byte chunks and a final missing newline" $ do+ let bytes = codexMessage "item.completed" "earlier" <> BS.init (codexMessage "item.completed" "final λ")+ report <- parseCodex (map BS.singleton (BS.unpack bytes))+ report ^. #message @?= "final λ",+ testCase "malformed outer lines cannot displace the last completed answer" $ do+ report <- parseCodex [codexMessage "item.completed" "answer", "{broken\n", "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\"}}\n", "{\"type\":\"future-event\"}\n"]+ report ^. #message @?= "answer",+ testCase "a recorded run yields its text, thread id, and token counts" $ do recorded <- BS.readFile "test/fixtures/codex-events.jsonl" report <- parseCodex [recorded] report ^. #message @?= "ok"@@ -189,6 +236,10 @@ "assembling one two-megabyte event did not finish within ten seconds" Just report -> Text.length (report ^. #message) @?= 2000000 ]++codexMessage :: Text.Text -> Text.Text -> ByteString+codexMessage eventType body =+ LBS.toStrict (Aeson.encode (Aeson.object ["type" Aeson..= eventType, "item" Aeson..= Aeson.object ["type" Aeson..= ("agent_message" :: Text.Text), "text" Aeson..= body]])) <> "\n" -- ============================================================ -- The claude result document