Skip to content

Commit a0d0a61

Browse files
committed
feat(ai): unify codex live event rendering with history output
Add synthetic claude.ToolUse objects to ai.Event.Raw for codex live streams, enabling the shared lineRenderer to format codex events identically to `captain history` output. This eliminates duplicate rendering logic and ensures consistent session banners, tool rows, and result footers with cost/usage information. Introduce three helper functions (codexToolUse, codexResultToolUse, codexSessionToolUse) to construct the synthetic ToolUse objects, and add renderCodexEntry to route codex events through the unified rendering path. Include comprehensive test coverage verifying the integration.
1 parent 55f9997 commit a0d0a61

3 files changed

Lines changed: 186 additions & 3 deletions

File tree

pkg/ai/provider/codex_cli.go

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"time"
1414

1515
"github.com/flanksource/captain/pkg/ai"
16+
"github.com/flanksource/captain/pkg/claude"
1617
"github.com/flanksource/commons/logger"
1718
)
1819

@@ -309,7 +310,9 @@ func mapCodexEvent(line, model string) (ai.Event, bool) {
309310
if len(msg.Input) > 0 {
310311
_ = json.Unmarshal(msg.Input, &input)
311312
}
312-
return ai.Event{Kind: ai.EventToolUse, Tool: msg.Name, Input: input, Model: model}, true
313+
out := ai.Event{Kind: ai.EventToolUse, Tool: msg.Name, Input: input, Model: model}
314+
out.Raw = codexToolUse(msg.Name, input, ev.ID, model)
315+
return out, true
313316
case "task_complete", "result", "completion":
314317
out := ai.Event{Kind: ai.EventResult, Tool: "Result", Model: model, Success: msg.Error == "", CostUSD: msg.Cost}
315318
if msg.Usage != nil {
@@ -318,6 +321,7 @@ func mapCodexEvent(line, model string) (ai.Event, bool) {
318321
if msg.Error != "" {
319322
out.Error = msg.Error
320323
}
324+
out.Raw = codexResultToolUse(out, ev.ID)
321325
return out, true
322326
case "error", "task_error":
323327
errText := firstNonEmpty(msg.Error, msg.Message, ev.Message)
@@ -326,12 +330,16 @@ func mapCodexEvent(line, model string) (ai.Event, bool) {
326330
}
327331
return ai.Event{Kind: ai.EventError, Error: extractCodexErrorText(errText), Model: model}, true
328332
case "session_configured", "session_init":
329-
return ai.Event{Kind: ai.EventSystem, Tool: "SessionInit", SessionID: ev.ID, Model: model}, true
333+
out := ai.Event{Kind: ai.EventSystem, Tool: "SessionInit", SessionID: ev.ID, Model: model}
334+
out.Raw = codexSessionToolUse(ev.ID, model)
335+
return out, true
330336

331337
// --- Newer dotted-type schema ---
332338
case "thread.started":
333339
sessionID := firstNonEmpty(ev.ThreadID, ev.ID)
334-
return ai.Event{Kind: ai.EventSystem, Tool: "SessionInit", SessionID: sessionID, Model: model}, true
340+
out := ai.Event{Kind: ai.EventSystem, Tool: "SessionInit", SessionID: sessionID, Model: model}
341+
out.Raw = codexSessionToolUse(sessionID, model)
342+
return out, true
335343
case "turn.started":
336344
// Heartbeat — captain does not surface this.
337345
return ai.Event{}, false
@@ -353,6 +361,7 @@ func mapCodexEvent(line, model string) (ai.Event, bool) {
353361
if ev.Usage != nil {
354362
out.Usage = &ai.Usage{InputTokens: ev.Usage.InputTokens, OutputTokens: ev.Usage.OutputTokens}
355363
}
364+
out.Raw = codexResultToolUse(out, firstNonEmpty(ev.ThreadID, ev.ID))
356365
return out, true
357366
case "turn.failed":
358367
var errText string
@@ -374,6 +383,67 @@ func firstNonEmpty(values ...string) string {
374383
return ""
375384
}
376385

386+
// codexToolUse builds the claude.ToolUse stand-in that mapCodexEvent stashes
387+
// on ai.Event.Raw so the shared lineRenderer in pkg/cli renders codex live
388+
// streams identically to `captain history` output. Source is hard-coded to
389+
// "codex" so sessionKey/sessionHeaderText pick the codex icon and label.
390+
func codexToolUse(name string, input map[string]any, sessionID, model string) claude.ToolUse {
391+
return claude.ToolUse{
392+
Tool: name,
393+
Input: input,
394+
SessionID: sessionID,
395+
Source: "codex",
396+
Model: model,
397+
}
398+
}
399+
400+
// codexResultToolUse mirrors the synthetic Result row that
401+
// pkg/cli/ai.go's renderResultEvent constructs for Claude, preserving cost,
402+
// usage, and error state so the "🏁 result …" footer renders consistently.
403+
func codexResultToolUse(ev ai.Event, sessionID string) claude.ToolUse {
404+
input := map[string]any{}
405+
for k, v := range ev.Input {
406+
input[k] = v
407+
}
408+
if ev.CostUSD > 0 {
409+
if _, ok := input["total_cost_usd"]; !ok {
410+
input["total_cost_usd"] = ev.CostUSD
411+
}
412+
}
413+
tu := claude.ToolUse{
414+
Tool: "Result",
415+
Input: input,
416+
SessionID: sessionID,
417+
Source: "codex",
418+
Model: ev.Model,
419+
}
420+
if ev.Usage != nil {
421+
tu.InputTokens = ev.Usage.InputTokens
422+
tu.OutputTokens = ev.Usage.OutputTokens
423+
}
424+
if !ev.Success {
425+
tu.IsError = true
426+
if ev.Error != "" {
427+
if _, ok := tu.Input["result"]; !ok {
428+
tu.Input["result"] = ev.Error
429+
}
430+
}
431+
}
432+
return tu
433+
}
434+
435+
// codexSessionToolUse builds the synthetic SessionInit row used by the
436+
// shared renderer to emit a session-start banner the first time a sessionKey
437+
// is seen. The lineRenderer treats SessionInit just like any other tool row.
438+
func codexSessionToolUse(sessionID, model string) claude.ToolUse {
439+
return claude.ToolUse{
440+
Tool: "SessionInit",
441+
SessionID: sessionID,
442+
Source: "codex",
443+
Model: model,
444+
}
445+
}
446+
377447
// redactCodexArgs returns a copy of args with the trailing prompt truncated so
378448
// debug logs stay readable. The prompt is the only positional argument and
379449
// always sits at the end of the argv built by buildCodexExecArgs.

pkg/cli/ai.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ func renderEvent(w *os.File, renderer *lineRenderer, ev ai.Event) {
269269
return
270270
}
271271
}
272+
if tu, ok := ev.Raw.(claude.ToolUse); ok {
273+
if renderCodexEntry(renderer, ev, tu) {
274+
return
275+
}
276+
}
272277

273278
switch ev.Kind {
274279
case ai.EventText:
@@ -346,6 +351,27 @@ func renderClaudeEntry(renderer *lineRenderer, ev ai.Event, entry claude.History
346351
return true
347352
}
348353

354+
// renderCodexEntry mirrors renderClaudeEntry for codex live events, which
355+
// stash a synthesized claude.ToolUse on ev.Raw rather than a HistoryEntry
356+
// (codex's stream schema does not match Claude's message-shaped envelope).
357+
// Routing the codex tool use through ToolUsesToTools keeps the rendering
358+
// path identical to `captain history` for codex JSONL.
359+
func renderCodexEntry(renderer *lineRenderer, ev ai.Event, tu claude.ToolUse) bool {
360+
switch ev.Kind {
361+
case ai.EventToolUse, ai.EventResult, ai.EventSystem:
362+
default:
363+
return false
364+
}
365+
tl := claude.ToolUsesToTools([]claude.ToolUse{tu})
366+
if len(tl) == 0 {
367+
return false
368+
}
369+
for _, t := range tl {
370+
renderer.Render(t, true)
371+
}
372+
return true
373+
}
374+
349375
func summariseInput(input map[string]any) string {
350376
if len(input) == 0 {
351377
return ""

pkg/cli/ai_render_codex_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
8+
"github.com/flanksource/captain/pkg/ai"
9+
"github.com/flanksource/captain/pkg/claude"
10+
)
11+
12+
// TestRenderEvent_CodexLiveUsesLineRenderer verifies that codex live events
13+
// flow through the same shared lineRenderer as `captain history` does for
14+
// codex JSONL — emitting a session-start banner, a tool row, and a result row
15+
// with cost/usage. Without unification, renderEvent falls back to the bare
16+
// "[tool] name" / "[result] ..." printer.
17+
func TestRenderEvent_CodexLiveUsesLineRenderer(t *testing.T) {
18+
var buf bytes.Buffer
19+
renderer := newLineRenderer(&buf, 8)
20+
21+
session := claude.ToolUse{
22+
Tool: "SessionInit",
23+
SessionID: "019e0365-dc2a-7ad0-a5a8-78936481a928",
24+
Source: "codex",
25+
Model: "gpt-5",
26+
}
27+
exec := claude.ToolUse{
28+
Tool: "Bash",
29+
Input: map[string]any{"command": "ls"},
30+
SessionID: "019e0365-dc2a-7ad0-a5a8-78936481a928",
31+
Source: "codex",
32+
Model: "gpt-5",
33+
}
34+
result := claude.ToolUse{
35+
Tool: "Result",
36+
Input: map[string]any{"total_cost_usd": 0.5},
37+
SessionID: "019e0365-dc2a-7ad0-a5a8-78936481a928",
38+
Source: "codex",
39+
Model: "gpt-5",
40+
InputTokens: 100,
41+
OutputTokens: 50,
42+
}
43+
44+
renderEvent(nil, renderer, ai.Event{
45+
Kind: ai.EventSystem,
46+
Tool: "SessionInit",
47+
SessionID: session.SessionID,
48+
Model: session.Model,
49+
Raw: session,
50+
})
51+
renderEvent(nil, renderer, ai.Event{
52+
Kind: ai.EventToolUse,
53+
Tool: exec.Tool,
54+
Input: exec.Input,
55+
Model: exec.Model,
56+
Raw: exec,
57+
})
58+
renderEvent(nil, renderer, ai.Event{
59+
Kind: ai.EventResult,
60+
Tool: "Result",
61+
Model: result.Model,
62+
Success: true,
63+
CostUSD: 0.5,
64+
Usage: &ai.Usage{InputTokens: 100, OutputTokens: 50},
65+
Raw: result,
66+
})
67+
68+
out := buf.String()
69+
for _, want := range []string{
70+
"Codex", // session header capitalises the source name
71+
"gpt-5", // model in the header
72+
"019e0365", // shortened session id
73+
"Bash", // tool row label
74+
"$0.5", // cost
75+
} {
76+
if !strings.Contains(out, want) {
77+
t.Errorf("rendered output missing %q\nfull output:\n%s", want, out)
78+
}
79+
}
80+
81+
// Negative: the bare fallback printer must NOT be used when Raw is set.
82+
for _, forbidden := range []string{"[tool]", "[result]", "[session]"} {
83+
if strings.Contains(out, forbidden) {
84+
t.Errorf("output should not contain bare fallback %q\nfull output:\n%s", forbidden, out)
85+
}
86+
}
87+
}

0 commit comments

Comments
 (0)