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 {