diff --git a/frontend/src/lib/utils/messages.test.ts b/frontend/src/lib/utils/messages.test.ts
index 648e3585..0dd78edc 100644
--- a/frontend/src/lib/utils/messages.test.ts
+++ b/frontend/src/lib/utils/messages.test.ts
@@ -49,6 +49,7 @@ describe("isSystemMessage", () => {
["continuation", "This session is being continued from a previous..."],
["interrupted", "[Request interrupted by user]"],
["task-notification", "done"],
+ ["subagent-notification", "{\"agent_id\":\"abc\"}"],
["command-message", "commit"],
["command-name", "/commit"],
["local-command", "ok"],
diff --git a/frontend/src/lib/utils/messages.ts b/frontend/src/lib/utils/messages.ts
index 18d9ba95..85faa124 100644
--- a/frontend/src/lib/utils/messages.ts
+++ b/frontend/src/lib/utils/messages.ts
@@ -4,6 +4,7 @@ const SYSTEM_MSG_PREFIXES = [
"This session is being continued",
"[Request interrupted",
"",
+ "",
"",
"",
" msg.Ordinal {
+ idx = i
+ break
+ }
+ }
+ b.messages = append(b.messages, ParsedMessage{})
+ copy(b.messages[idx+1:], b.messages[idx:])
+ b.messages[idx] = msg
+ for callID, cur := range b.callResultIndex {
+ if cur >= idx {
+ b.callResultIndex[callID] = cur + 1
+ }
+ }
+ return idx
}
func formatCodexFunctionCall(
@@ -175,6 +419,8 @@ func formatCodexFunctionCall(
return formatCodexWriteStdinCall(summary, args, rawArgs)
case "apply_patch":
return formatCodexApplyPatchCall(summary, args, rawArgs)
+ case "spawn_agent":
+ return formatCodexSpawnAgentCall(summary, args, rawArgs)
}
category := NormalizeToolCategory(name)
@@ -358,6 +604,33 @@ func formatCodexApplyPatchCall(
return header
}
+func formatCodexSpawnAgentCall(
+ summary string, args gjson.Result, rawArgs string,
+) string {
+ if summary == "" {
+ summary = firstNonEmpty(
+ codexArgValue(args, "agent_type"),
+ codexArgValue(args, "subagent_type"),
+ "spawn_agent",
+ )
+ }
+
+ header := formatToolHeader("Task", summary)
+ prompt := firstNonEmpty(
+ codexArgValue(args, "description"),
+ codexArgValue(args, "message"),
+ codexArgValue(args, "prompt"),
+ )
+ if prompt != "" {
+ firstLine, _, _ := strings.Cut(prompt, "\n")
+ return header + "\n" + truncate(firstLine, 220)
+ }
+ if preview := codexArgPreview(args, rawArgs); preview != "" {
+ return header + "\n" + preview
+ }
+ return header
+}
+
func extractPatchedFiles(patch string) []string {
if patch == "" {
return nil
@@ -515,6 +788,114 @@ func firstNonEmpty(vals ...string) string {
return ""
}
+func parseCodexFunctionOutput(
+ payload gjson.Result,
+) (gjson.Result, string) {
+ out := payload.Get("output")
+ if !out.Exists() {
+ return gjson.Result{}, ""
+ }
+
+ switch out.Type {
+ case gjson.String:
+ s := strings.TrimSpace(out.Str)
+ if s == "" {
+ return gjson.Result{}, ""
+ }
+ if gjson.Valid(s) {
+ return gjson.Parse(s), s
+ }
+ return gjson.Result{}, s
+ default:
+ raw := strings.TrimSpace(out.Raw)
+ if raw == "" {
+ return gjson.Result{}, ""
+ }
+ if gjson.Valid(raw) {
+ return gjson.Parse(raw), raw
+ }
+ return gjson.Result{}, raw
+ }
+}
+
+func formatCodexCallResults(
+ entries map[string]codexPendingResult,
+ agentNames map[string]string,
+) string {
+ if len(entries) == 0 {
+ return ""
+ }
+
+ parts := make([]string, 0, len(entries))
+ ids := make([]string, 0, len(entries))
+ for agentID := range entries {
+ ids = append(ids, agentID)
+ }
+ sort.Strings(ids)
+ multi := len(entries) > 1
+ for _, agentID := range ids {
+ text := entries[agentID].text
+ if !multi {
+ parts = append(parts, text)
+ continue
+ }
+ label := agentID
+ if name := strings.TrimSpace(agentNames[agentID]); name != "" {
+ label = fmt.Sprintf("%s (%s)", name, agentID)
+ }
+ parts = append(parts, label+":\n"+text)
+ }
+
+ return strings.Join(parts, "\n\n")
+}
+
+func codexWaitAgentIDs(args gjson.Result) []string {
+ if !args.Exists() {
+ return nil
+ }
+ ids := args.Get("ids")
+ if !ids.Exists() || !ids.IsArray() {
+ return nil
+ }
+
+ var out []string
+ for _, item := range ids.Array() {
+ id := strings.TrimSpace(item.Str)
+ if id == "" {
+ continue
+ }
+ out = append(out, id)
+ }
+ return out
+}
+
+func parseCodexSubagentNotification(
+ content string,
+) (agentID, text string) {
+ if !isCodexSubagentNotification(content) {
+ return "", ""
+ }
+ body := strings.TrimSpace(content)
+ body = strings.TrimPrefix(body, "")
+ body = strings.TrimSuffix(body, "")
+ body = strings.TrimSpace(body)
+ if !gjson.Valid(body) {
+ return "", ""
+ }
+ parsed := gjson.Parse(body)
+ agentID = strings.TrimSpace(parsed.Get("agent_id").Str)
+ status := parsed.Get("status")
+ text = codexTerminalSubagentStatus(status)
+ return agentID, text
+}
+
+func codexTerminalSubagentStatus(status gjson.Result) string {
+ return firstNonEmpty(
+ status.Get("completed").Str,
+ status.Get("errored").Str,
+ )
+}
+
// extractCodexContent joins all text blocks from a Codex
// response item's content array.
func extractCodexContent(payload gjson.Result) string {
@@ -571,6 +952,9 @@ func ParseCodexSession(
fmt.Errorf("reading codex %s: %w", path, err)
}
+ b.flushPendingAgentResults()
+ annotateSubagentSessions(b.messages, b.subagentMap)
+
sessionID := b.sessionID
if sessionID == "" {
sessionID = strings.TrimSuffix(
@@ -619,15 +1003,23 @@ func ParseCodexSessionFrom(
) ([]ParsedMessage, time.Time, int64, error) {
b := newCodexSessionBuilder(includeExec)
b.ordinal = startOrdinal
+ var fallbackErr error
consumed, err := readJSONLFrom(
path, offset, func(line string) {
+ if fallbackErr != nil {
+ return
+ }
// Skip session_meta — already processed in
// the initial full parse.
if gjson.Get(line, "type").Str ==
codexTypeSessionMeta {
return
}
+ if codexIncrementalNeedsFullParse(line) {
+ fallbackErr = errCodexIncrementalNeedsFullParse
+ return
+ }
b.processLine(line)
},
)
@@ -637,6 +1029,12 @@ func ParseCodexSessionFrom(
path, offset, err,
)
}
+ if fallbackErr != nil {
+ return nil, time.Time{}, 0, fallbackErr
+ }
+
+ b.flushPendingAgentResults()
+ annotateSubagentSessions(b.messages, b.subagentMap)
return b.messages, b.endedAt, consumed, nil
}
@@ -644,5 +1042,40 @@ func ParseCodexSessionFrom(
func isCodexSystemMessage(content string) bool {
return strings.HasPrefix(content, "# AGENTS.md") ||
strings.HasPrefix(content, "") ||
- strings.HasPrefix(content, "")
+ strings.HasPrefix(content, "") ||
+ isCodexSubagentNotification(content)
+}
+
+func isCodexSubagentNotification(content string) bool {
+ return strings.HasPrefix(
+ strings.TrimSpace(content),
+ "",
+ )
+}
+
+func codexIncrementalNeedsFullParse(line string) bool {
+ if gjson.Get(line, "type").Str != codexTypeResponseItem {
+ return false
+ }
+
+ payload := gjson.Get(line, "payload")
+ switch payload.Get("type").Str {
+ case "function_call":
+ return payload.Get("name").Str == "wait"
+ case "function_call_output":
+ output, _ := parseCodexFunctionOutput(payload)
+ if !output.Exists() {
+ return false
+ }
+ return strings.TrimSpace(output.Get("agent_id").Str) != "" ||
+ output.Get("status").Exists()
+ default:
+ role := payload.Get("role").Str
+ if role != "user" {
+ return false
+ }
+ return isCodexSubagentNotification(
+ extractCodexContent(payload),
+ )
+ }
}
diff --git a/internal/parser/codex_parser_test.go b/internal/parser/codex_parser_test.go
index 5670c56f..cd6c0451 100644
--- a/internal/parser/codex_parser_test.go
+++ b/internal/parser/codex_parser_test.go
@@ -129,6 +129,310 @@ func TestParseCodexSession_FunctionCalls(t *testing.T) {
assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ToolName: "Agent", Category: "Task"}})
})
+ t.Run("spawn_agent links child session and wait output becomes tool result", func(t *testing.T) {
+ childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
+ waitSummary := "Exit code: `1`\n\nFull output:\n```text\nTraceback...\n```"
+ notification := "\n" +
+ "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" +
+ ""
+ content := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("fc-subagent", "/tmp", "user", tsEarly),
+ testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1),
+ testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{
+ "agent_type": "awaiter",
+ "message": "Run the compile smoke test",
+ }, tsEarlyS5),
+ testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate),
+ testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{
+ "ids": []string{childID},
+ "timeout_ms": 600000,
+ }, tsLateS5),
+ testjsonl.CodexFunctionCallOutputJSON("call_wait", "{\"status\":{\""+childID+"\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}}", "2024-01-01T10:01:06Z"),
+ testjsonl.CodexMsgJSON("user", notification, "2024-01-01T10:01:07Z"),
+ testjsonl.CodexMsgJSON("assistant", "continuing", "2024-01-01T10:01:08Z"),
+ )
+ sess, msgs := runCodexParserTest(t, "test.jsonl", content, false)
+
+ require.NotNil(t, sess)
+ assert.Equal(t, 5, len(msgs))
+ assert.Equal(t, RoleAssistant, msgs[1].Role)
+ assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{
+ ToolUseID: "call_spawn",
+ ToolName: "spawn_agent",
+ Category: "Task",
+ SubagentSessionID: "codex:" + childID,
+ }})
+ assert.Equal(t, RoleAssistant, msgs[2].Role)
+ assertToolCalls(t, msgs[2].ToolCalls, []ParsedToolCall{{
+ ToolUseID: "call_wait",
+ ToolName: "wait",
+ Category: "Other",
+ }})
+ assert.Equal(t, RoleUser, msgs[3].Role)
+ assert.Empty(t, msgs[3].Content)
+ require.Len(t, msgs[3].ToolResults, 1)
+ assert.Equal(t, "call_wait", msgs[3].ToolResults[0].ToolUseID)
+ assert.Equal(t, waitSummary, DecodeContent(msgs[3].ToolResults[0].ContentRaw))
+ assert.Equal(t, RoleAssistant, msgs[4].Role)
+ assert.Equal(t, "continuing", msgs[4].Content)
+ })
+
+ t.Run("subagent notification without wait result falls back to spawn_agent output", func(t *testing.T) {
+ childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
+ summary := "Exit code: `1`\n\nFull output:\n```text\nTraceback...\n```"
+ notification := "\n" +
+ "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" +
+ ""
+ content := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("fc-subagent-notify", "/tmp", "user", tsEarly),
+ testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1),
+ testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{
+ "agent_type": "awaiter",
+ "message": "Run the compile smoke test",
+ }, tsEarlyS5),
+ testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate),
+ testjsonl.CodexMsgJSON("user", notification, tsLateS5),
+ )
+ sess, msgs := runCodexParserTest(t, "test.jsonl", content, false)
+
+ require.NotNil(t, sess)
+ assert.Equal(t, 3, len(msgs))
+ assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{
+ ToolUseID: "call_spawn",
+ ToolName: "spawn_agent",
+ Category: "Task",
+ SubagentSessionID: "codex:" + childID,
+ }})
+ assert.Equal(t, RoleUser, msgs[2].Role)
+ assert.Empty(t, msgs[2].Content)
+ require.Len(t, msgs[2].ToolResults, 1)
+ assert.Equal(t, "call_spawn", msgs[2].ToolResults[0].ToolUseID)
+ assert.Equal(t, summary, DecodeContent(msgs[2].ToolResults[0].ContentRaw))
+ })
+
+ t.Run("no-wait fallback preserves chronology before later messages", func(t *testing.T) {
+ childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
+ summary := "Exit code: `1`\n\nFull output:\n```text\nTraceback...\n```"
+ notification := "\n" +
+ "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" +
+ ""
+ content := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("fc-subagent-notify-order", "/tmp", "user", tsEarly),
+ testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1),
+ testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{
+ "agent_type": "awaiter",
+ "message": "Run the compile smoke test",
+ }, tsEarlyS5),
+ testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate),
+ testjsonl.CodexMsgJSON("user", notification, tsLateS5),
+ testjsonl.CodexMsgJSON("assistant", "continuing", "2024-01-01T10:01:06Z"),
+ )
+ sess, msgs := runCodexParserTest(t, "test.jsonl", content, false)
+
+ require.NotNil(t, sess)
+ assert.Equal(t, 4, len(msgs))
+ require.Len(t, msgs[2].ToolResults, 1)
+ assert.Equal(t, "call_spawn", msgs[2].ToolResults[0].ToolUseID)
+ assert.Equal(t, summary, DecodeContent(msgs[2].ToolResults[0].ContentRaw))
+ assert.Equal(t, RoleAssistant, msgs[3].Role)
+ assert.Equal(t, "continuing", msgs[3].Content)
+ })
+
+ t.Run("duplicate pending notification preserves earliest chronology", func(t *testing.T) {
+ childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
+ summary := "Exit code: `1`\n\nFull output:\n```text\nTraceback...\n```"
+ notification := "\n" +
+ "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Exit code: `1`\\n\\nFull output:\\n```text\\nTraceback...\\n```\"}}\n" +
+ ""
+ content := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("fc-subagent-notify-dupe-order", "/tmp", "user", tsEarly),
+ testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1),
+ testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{
+ "agent_type": "awaiter",
+ "message": "Run the compile smoke test",
+ }, tsEarlyS5),
+ testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate),
+ testjsonl.CodexMsgJSON("user", notification, tsLateS5),
+ testjsonl.CodexMsgJSON("assistant", "continuing", "2024-01-01T10:01:06Z"),
+ testjsonl.CodexMsgJSON("user", notification, "2024-01-01T10:01:07Z"),
+ )
+ sess, msgs := runCodexParserTest(t, "test.jsonl", content, false)
+
+ require.NotNil(t, sess)
+ assert.Equal(t, 4, len(msgs))
+ require.Len(t, msgs[2].ToolResults, 1)
+ assert.Equal(t, "call_spawn", msgs[2].ToolResults[0].ToolUseID)
+ assert.Equal(t, summary, DecodeContent(msgs[2].ToolResults[0].ContentRaw))
+ assert.Equal(t, RoleAssistant, msgs[3].Role)
+ assert.Equal(t, "continuing", msgs[3].Content)
+ })
+
+ t.Run("running subagent notification does not suppress later completion", func(t *testing.T) {
+ childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
+ running := "\n" +
+ "{\"agent_id\":\"" + childID + "\",\"status\":{\"running\":\"Still working\"}}\n" +
+ ""
+ completed := "\n" +
+ "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
+ ""
+ content := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("fc-subagent-running", "/tmp", "user", tsEarly),
+ testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1),
+ testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{
+ "agent_type": "awaiter",
+ "message": "Run the compile smoke test",
+ }, tsEarlyS5),
+ testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate),
+ testjsonl.CodexMsgJSON("user", running, tsLateS5),
+ testjsonl.CodexMsgJSON("user", completed, "2024-01-01T10:01:06Z"),
+ )
+ sess, msgs := runCodexParserTest(t, "test.jsonl", content, false)
+
+ require.NotNil(t, sess)
+ assert.Equal(t, 3, len(msgs))
+ assert.Equal(t, RoleUser, msgs[2].Role)
+ assert.Empty(t, msgs[2].Content)
+ require.Len(t, msgs[2].ToolResults, 1)
+ assert.Equal(t, "call_spawn", msgs[2].ToolResults[0].ToolUseID)
+ assert.Equal(t, "Finished successfully", DecodeContent(msgs[2].ToolResults[0].ContentRaw))
+ })
+
+ t.Run("notification after wait binds to wait call", func(t *testing.T) {
+ childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
+ completed := "\n" +
+ "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
+ ""
+ content := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("fc-subagent-wait-bind", "/tmp", "user", tsEarly),
+ testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1),
+ testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{
+ "agent_type": "awaiter",
+ "message": "Run the compile smoke test",
+ }, tsEarlyS5),
+ testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate),
+ testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{
+ "ids": []string{childID},
+ }, tsLateS5),
+ testjsonl.CodexMsgJSON("user", completed, "2024-01-01T10:01:06Z"),
+ )
+ sess, msgs := runCodexParserTest(t, "test.jsonl", content, false)
+
+ require.NotNil(t, sess)
+ assert.Equal(t, 4, len(msgs))
+ assertToolCalls(t, msgs[2].ToolCalls, []ParsedToolCall{{
+ ToolUseID: "call_wait",
+ ToolName: "wait",
+ Category: "Other",
+ }})
+ require.Len(t, msgs[3].ToolResults, 1)
+ assert.Equal(t, "call_wait", msgs[3].ToolResults[0].ToolUseID)
+ assert.Equal(t, "Finished successfully", DecodeContent(msgs[3].ToolResults[0].ContentRaw))
+ })
+
+ t.Run("notification before wait binds to later wait call", func(t *testing.T) {
+ childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
+ completed := "\n" +
+ "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
+ ""
+ content := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("fc-subagent-wait-rebind", "/tmp", "user", tsEarly),
+ testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1),
+ testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{
+ "agent_type": "awaiter",
+ "message": "Run the compile smoke test",
+ }, tsEarlyS5),
+ testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate),
+ testjsonl.CodexMsgJSON("user", completed, tsLateS5),
+ testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{
+ "ids": []string{childID},
+ }, "2024-01-01T10:01:06Z"),
+ )
+ sess, msgs := runCodexParserTest(t, "test.jsonl", content, false)
+
+ require.NotNil(t, sess)
+ assert.Equal(t, 4, len(msgs))
+ require.Len(t, msgs[3].ToolResults, 1)
+ assert.Equal(t, "call_wait", msgs[3].ToolResults[0].ToolUseID)
+ assert.Equal(t, "Finished successfully", DecodeContent(msgs[3].ToolResults[0].ContentRaw))
+ })
+
+ t.Run("wait output does not duplicate terminal notification result", func(t *testing.T) {
+ childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
+ completed := "\n" +
+ "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
+ ""
+ content := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("fc-subagent-wait-dedupe", "/tmp", "user", tsEarly),
+ testjsonl.CodexMsgJSON("user", "run a child agent", tsEarlyS1),
+ testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{
+ "agent_type": "awaiter",
+ "message": "Run the compile smoke test",
+ }, tsEarlyS5),
+ testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate),
+ testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{
+ "ids": []string{childID},
+ }, tsLateS5),
+ testjsonl.CodexMsgJSON("user", completed, "2024-01-01T10:01:06Z"),
+ testjsonl.CodexFunctionCallOutputJSON("call_wait",
+ "{\"status\":{\""+childID+"\":{\"completed\":\"Finished successfully\"}}}",
+ "2024-01-01T10:01:07Z",
+ ),
+ )
+ sess, msgs := runCodexParserTest(t, "test.jsonl", content, false)
+
+ require.NotNil(t, sess)
+ assert.Equal(t, 4, len(msgs))
+ require.Len(t, msgs[3].ToolResults, 1)
+ assert.Equal(t, "call_wait", msgs[3].ToolResults[0].ToolUseID)
+ assert.Equal(t, "Finished successfully", DecodeContent(msgs[3].ToolResults[0].ContentRaw))
+ })
+
+ t.Run("mixed wait status preserves later completion for running agent", func(t *testing.T) {
+ completedID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
+ runningID := "019c9c96-6ee7-77c0-ba4c-380f844289d6"
+ laterCompleted := "\n" +
+ "{\"agent_id\":\"" + runningID + "\",\"status\":{\"completed\":\"Second agent finished\"}}\n" +
+ ""
+ content := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("fc-subagent-mixed-wait", "/tmp", "user", tsEarly),
+ testjsonl.CodexMsgJSON("user", "run child agents", tsEarlyS1),
+ testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{
+ "ids": []string{completedID, runningID},
+ }, tsEarlyS5),
+ testjsonl.CodexFunctionCallOutputJSON("call_wait",
+ "{\"status\":{\""+completedID+"\":{\"completed\":\"First agent finished\"},\""+runningID+"\":{\"running\":\"Still working\"}}}",
+ tsLate,
+ ),
+ testjsonl.CodexMsgJSON("user", laterCompleted, tsLateS5),
+ )
+ sess, msgs := runCodexParserTest(t, "test.jsonl", content, false)
+
+ require.NotNil(t, sess)
+ assert.Equal(t, 3, len(msgs))
+ require.Len(t, msgs[2].ToolResults, 1)
+ assert.Equal(t, "call_wait", msgs[2].ToolResults[0].ToolUseID)
+ decoded := DecodeContent(msgs[2].ToolResults[0].ContentRaw)
+ assert.Contains(t, decoded, "First agent finished")
+ assert.Contains(t, decoded, "Second agent finished")
+ })
+
+ t.Run("orphaned terminal notifications dedupe", func(t *testing.T) {
+ childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
+ completed := "\n" +
+ "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
+ ""
+ content := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("fc-subagent-orphan", "/tmp", "user", tsEarly),
+ testjsonl.CodexMsgJSON("user", completed, tsEarlyS1),
+ testjsonl.CodexMsgJSON("user", completed, tsEarlyS5),
+ )
+ sess, msgs := runCodexParserTest(t, "test.jsonl", content, false)
+
+ require.NotNil(t, sess)
+ assert.Equal(t, 1, len(msgs))
+ assert.Equal(t, "Finished successfully", msgs[0].Content)
+ })
+
t.Run("function call no name skipped", func(t *testing.T) {
content := testjsonl.JoinJSONL(
testjsonl.CodexSessionMetaJSON("fc-2", "/tmp", "user", tsEarly),
@@ -518,3 +822,98 @@ func TestParseCodexSessionFrom_NoNewData(t *testing.T) {
assert.Equal(t, 0, len(newMsgs))
assert.True(t, endedAt.IsZero())
}
+
+func TestParseCodexSessionFrom_SubagentOutputRequiresFullParse(t *testing.T) {
+ t.Parallel()
+
+ initial := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("inc-sub", "/tmp", "codex_cli_rs", tsEarly),
+ testjsonl.CodexMsgJSON("user", "run child", tsEarlyS1),
+ testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{
+ "agent_type": "awaiter",
+ "message": "run it",
+ }, tsEarlyS5),
+ )
+ path := createTestFile(t, "codex-subagent-inc.jsonl", initial)
+
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+ offset := info.Size()
+
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
+ require.NoError(t, err)
+ _, err = f.WriteString(testjsonl.JoinJSONL(
+ testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"019c9c96-6ee7-77c0-ba4c-380f844289d5","nickname":"Fennel"}`, tsLate),
+ ))
+ require.NoError(t, err)
+ require.NoError(t, f.Close())
+
+ _, _, _, err = ParseCodexSessionFrom(path, offset, 2, false)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "full parse")
+}
+
+func TestParseCodexSessionFrom_WaitCallRequiresFullParse(t *testing.T) {
+ t.Parallel()
+
+ childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
+ notification := "\n" +
+ "{\"agent_id\":\"" + childID + "\",\"status\":{\"completed\":\"Finished successfully\"}}\n" +
+ ""
+ initial := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("inc-wait", "/tmp", "codex_cli_rs", tsEarly),
+ testjsonl.CodexMsgJSON("user", "run child", tsEarlyS1),
+ testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{
+ "agent_type": "awaiter",
+ "message": "run it",
+ }, tsEarlyS5),
+ testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, tsLate),
+ testjsonl.CodexMsgJSON("user", notification, tsLateS5),
+ )
+ path := createTestFile(t, "codex-wait-inc.jsonl", initial)
+
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+ offset := info.Size()
+
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
+ require.NoError(t, err)
+ _, err = f.WriteString(testjsonl.JoinJSONL(
+ testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{
+ "ids": []string{childID},
+ }, "2024-01-01T10:01:06Z"),
+ ))
+ require.NoError(t, err)
+ require.NoError(t, f.Close())
+
+ _, _, _, err = ParseCodexSessionFrom(path, offset, 4, false)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "full parse")
+}
+
+func TestParseCodexSessionFrom_SystemMessageDoesNotRequireFullParse(t *testing.T) {
+ t.Parallel()
+
+ initial := testjsonl.JoinJSONL(
+ testjsonl.CodexSessionMetaJSON("inc-system", "/tmp", "codex_cli_rs", tsEarly),
+ testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1),
+ )
+ path := createTestFile(t, "codex-system-inc.jsonl", initial)
+
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+ offset := info.Size()
+
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
+ require.NoError(t, err)
+ _, err = f.WriteString(testjsonl.JoinJSONL(
+ testjsonl.CodexMsgJSON("user", "# AGENTS.md\nsome instructions", tsLate),
+ ))
+ require.NoError(t, err)
+ require.NoError(t, f.Close())
+
+ newMsgs, endedAt, _, err := ParseCodexSessionFrom(path, offset, 1, false)
+ require.NoError(t, err)
+ assert.Equal(t, 0, len(newMsgs))
+ assert.False(t, endedAt.IsZero())
+}
diff --git a/internal/parser/taxonomy.go b/internal/parser/taxonomy.go
index 657a9d6a..6e0d07c0 100644
--- a/internal/parser/taxonomy.go
+++ b/internal/parser/taxonomy.go
@@ -31,6 +31,8 @@ func NormalizeToolCategory(rawName string) string {
return "Bash"
case "apply_patch":
return "Edit"
+ case "spawn_agent":
+ return "Task"
// Gemini tools
case "read_file", "list_directory":
diff --git a/internal/testjsonl/testjsonl.go b/internal/testjsonl/testjsonl.go
index ba1b5fc6..1ace7861 100644
--- a/internal/testjsonl/testjsonl.go
+++ b/internal/testjsonl/testjsonl.go
@@ -217,6 +217,44 @@ func CodexFunctionCallFieldsJSON(
return mustMarshal(m)
}
+// CodexFunctionCallWithCallIDJSON returns a Codex function_call
+// response_item with an explicit call_id.
+func CodexFunctionCallWithCallIDJSON(
+ name, callID string, arguments any, timestamp string,
+) string {
+ payload := map[string]any{
+ "type": "function_call",
+ "name": name,
+ "call_id": callID,
+ }
+ if arguments != nil {
+ payload["arguments"] = arguments
+ }
+ m := map[string]any{
+ "type": "response_item",
+ "timestamp": timestamp,
+ "payload": payload,
+ }
+ return mustMarshal(m)
+}
+
+// CodexFunctionCallOutputJSON returns a Codex
+// function_call_output response_item.
+func CodexFunctionCallOutputJSON(
+ callID string, output any, timestamp string,
+) string {
+ m := map[string]any{
+ "type": "response_item",
+ "timestamp": timestamp,
+ "payload": map[string]any{
+ "type": "function_call_output",
+ "call_id": callID,
+ "output": output,
+ },
+ }
+ return mustMarshal(m)
+}
+
// CodexTurnContextJSON returns a Codex turn_context entry as a
// JSON string with the given model.
func CodexTurnContextJSON(model, timestamp string) string {