Skip to content

Commit c6c9afd

Browse files
authored
feat: model Codex subagent result events (#247)
Closes #233. Supersedes #242. ## Summary - model Codex subagent terminal status as first-class `tool_result_events` in SQLite and PostgreSQL - keep `tool_calls.result_content` as a derived/latest compatibility summary instead of the canonical storage shape - render the latest summary by default and expose full chronological subagent result history in the tool block UI - preserve event history across full resyncs, PG pushes, and orphan-copy recovery ## How This Differs From #242 `#242` tried to solve Codex subagent workflows inside the existing single-result model by: - rebinding `wait` / `<subagent_notification>` output into one mutable `tool_calls.result_content` - synthesizing tool-result-like transcript rows to preserve some ordering information - adding parser heuristics to decide which source should "own" the final blob That approach improved the raw JSON problem, but it kept running into ownership and chronology edge cases because Codex subagent status is not actually a single final blob. This PR switches to a different model: - add additive `tool_result_events` tables in both SQLite and PostgreSQL - attach chronological result events directly to the originating tool call - derive `tool_calls.result_content` from those events for compatibility/search/compact UI - keep the existing subagent session linkage on `spawn_agent` Practical effect: - chronology is preserved without retroactively mutating older transcript rows - `wait` output and fallback notifications are stored as canonical event history - PG-backed multi-host views and local SQLite views share the same result-event model ## Details - `spawn_agent` remains a `Task` tool call and still maps to `subagent_session_id` - terminal `wait` output becomes `tool_result_events` - terminal `<subagent_notification>` messages also become `tool_result_events` - blocked categories are enforced server-side for event history as well as the compact summary - orphan-copy / resync paths preserve stored event history for sessions whose source files no longer exist ## Upgrade / Rollout - SQLite: additive schema change plus `dataVersion` bump - PostgreSQL: additive schema change, no drop/recreate required - exporters should upgrade and run `agentsview pg push --full` once, or let the normal full-sync path re-export after resync Known limit: - sessions whose original Codex JSONL source is already gone can only preserve event history that was already stored in SQLite; they cannot derive brand new history from missing raw files ## Test Plan - `CC=/usr/bin/gcc CXX=/usr/bin/g++ CGO_ENABLED=1 go test -tags fts5 ./internal/parser ./internal/sync ./internal/db ./internal/postgres -count=1` - `cd frontend && npm test -- --run src/lib/components/content/ToolBlock.test.ts src/lib/utils/messages.test.ts` ## Local Review - local `codex`, `claude-code`, and `gemini` review runs covered both `default` and `security` - the first v2 pass surfaced issues around stale `tool_result_events`, blocked-category history leakage, and same-ordinal orphan chronology - those follow-up fixes are included in this branch
1 parent c85aa95 commit c6c9afd

File tree

20 files changed

+2674
-32
lines changed

20 files changed

+2674
-32
lines changed

frontend/src/lib/api/types/core.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ export interface ProjectInfo {
4141
session_count: number;
4242
}
4343

44+
/** Matches Go ToolResultEvent struct in internal/db/messages.go */
45+
export interface ToolResultEvent {
46+
tool_use_id?: string;
47+
agent_id?: string;
48+
subagent_session_id?: string;
49+
source: string;
50+
status: string;
51+
content: string;
52+
content_length: number;
53+
timestamp?: string;
54+
event_index: number;
55+
}
56+
4457
/** Matches Go ToolCall struct in internal/db/messages.go */
4558
export interface ToolCall {
4659
tool_name: string;
@@ -51,6 +64,7 @@ export interface ToolCall {
5164
result_content_length?: number;
5265
result_content?: string;
5366
subagent_session_id?: string;
67+
result_events?: ToolResultEvent[];
5468
}
5569

5670
/** Matches Go Message struct in internal/db/messages.go */

frontend/src/lib/components/content/ToolBlock.svelte

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020
let { content, label, toolCall, highlightQuery = "", isCurrentHighlight = false }: Props = $props();
2121
let userCollapsed: boolean = $state(true);
2222
let userOutputCollapsed: boolean = $state(true);
23+
let userHistoryCollapsed: boolean = $state(true);
2324
let userOverride: boolean = $state(false);
2425
let userOutputOverride: boolean = $state(false);
26+
let userHistoryOverride: boolean = $state(false);
2527
let searchExpandedInput: boolean = $state(false);
2628
let searchExpandedOutput: boolean = $state(false);
29+
let searchExpandedHistory: boolean = $state(false);
2730
let prevQuery: string = "";
2831
2932
// Auto-expand when a search match exists in input or output
@@ -41,14 +44,19 @@
4144
const inputText = (
4245
taskPrompt ?? content ?? fallbackContent ?? ""
4346
).toLowerCase();
47+
const historyText = (
48+
toolCall?.result_events?.map((event) => event.content).join("\n\n") ?? ""
49+
).toLowerCase();
4450
const outputText = (
45-
toolCall?.result_content ?? ""
51+
[toolCall?.result_content ?? "", historyText].filter(Boolean).join("\n\n")
4652
).toLowerCase();
4753
searchExpandedInput = inputText.includes(q);
4854
searchExpandedOutput = outputText.includes(q);
55+
searchExpandedHistory = historyText.includes(q);
4956
if (hq !== prevQuery) {
5057
userOverride = false;
5158
userOutputOverride = false;
59+
userHistoryOverride = false;
5260
prevQuery = hq;
5361
}
5462
});
@@ -63,6 +71,11 @@
6371
: searchExpandedOutput ? false
6472
: userOutputCollapsed,
6573
);
74+
let historyCollapsed = $derived(
75+
userHistoryOverride ? userHistoryCollapsed
76+
: searchExpandedHistory ? false
77+
: userHistoryCollapsed,
78+
);
6679
6780
let outputPreviewLine = $derived.by(() => {
6881
const rc = toolCall?.result_content;
@@ -71,6 +84,14 @@
7184
return (nl === -1 ? rc : rc.slice(0, nl)).slice(0, 100);
7285
});
7386
87+
let resultEvents = $derived(toolCall?.result_events ?? []);
88+
89+
let historyPreviewLine = $derived.by(() => {
90+
const last = resultEvents[resultEvents.length - 1];
91+
if (!last) return "";
92+
return `${last.status}: ${last.content.split("\n")[0]}`.slice(0, 100);
93+
});
94+
7495
/** Parsed input parameters from structured tool call data */
7596
let inputParams = $derived.by(() => {
7697
if (!toolCall?.input_json) return null;
@@ -247,6 +268,51 @@
247268
<pre class="tool-content output-content" use:applyHighlight={{ q: highlightQuery, current: isCurrentHighlight, content: toolCall.result_content }}>{@html escapeHTML(toolCall.result_content)}</pre>
248269
{/if}
249270
{/if}
271+
{#if resultEvents.length > 0}
272+
<button
273+
class="history-header"
274+
onclick={(e) => {
275+
e.stopPropagation();
276+
const sel = window.getSelection();
277+
if (sel && sel.toString().length > 0) return;
278+
userHistoryCollapsed = !userHistoryCollapsed;
279+
userHistoryOverride = true;
280+
}}
281+
>
282+
<span class="tool-chevron" class:open={!historyCollapsed}>
283+
&#9656;
284+
</span>
285+
<span class="output-label">history</span>
286+
{#if historyCollapsed && historyPreviewLine}
287+
<span class="tool-preview">{historyPreviewLine}</span>
288+
{/if}
289+
</button>
290+
{#if !historyCollapsed}
291+
<div class="result-history">
292+
{#each resultEvents as event (event.event_index)}
293+
<div class="result-event">
294+
<div class="result-event-meta">
295+
<span class="meta-tag">
296+
<span class="meta-label">status:</span>
297+
{event.status}
298+
</span>
299+
<span class="meta-tag">
300+
<span class="meta-label">source:</span>
301+
{event.source}
302+
</span>
303+
{#if event.agent_id}
304+
<span class="meta-tag">
305+
<span class="meta-label">agent:</span>
306+
{event.agent_id}
307+
</span>
308+
{/if}
309+
</div>
310+
<pre class="tool-content output-content history-content" use:applyHighlight={{ q: highlightQuery, current: isCurrentHighlight, content: event.content }}>{@html escapeHTML(event.content)}</pre>
311+
</div>
312+
{/each}
313+
</div>
314+
{/if}
315+
{/if}
250316
{/if}
251317
{#if subagentSessionId}
252318
<SubagentInline sessionId={subagentSessionId} />
@@ -364,6 +430,26 @@
364430
color: var(--text-primary);
365431
}
366432
433+
.history-header {
434+
display: flex;
435+
align-items: center;
436+
gap: 6px;
437+
padding: 5px 10px;
438+
width: 100%;
439+
text-align: left;
440+
font-size: 12px;
441+
color: var(--text-secondary);
442+
min-width: 0;
443+
border-top: 1px solid var(--border-muted);
444+
transition: background 0.1s;
445+
user-select: text;
446+
}
447+
448+
.history-header:hover {
449+
background: var(--bg-surface-hover);
450+
color: var(--text-primary);
451+
}
452+
367453
.output-label {
368454
font-family: var(--font-mono);
369455
font-weight: 500;
@@ -377,4 +463,24 @@
377463
max-height: 300px;
378464
overflow-y: auto;
379465
}
466+
467+
.result-history {
468+
border-top: 1px solid var(--border-muted);
469+
}
470+
471+
.result-event + .result-event {
472+
border-top: 1px solid var(--border-muted);
473+
}
474+
475+
.result-event-meta {
476+
display: flex;
477+
flex-wrap: wrap;
478+
gap: 6px;
479+
padding: 6px 14px 0;
480+
}
481+
482+
.history-content {
483+
border-top: 0;
484+
margin-top: 0;
485+
}
380486
</style>

frontend/src/lib/components/content/ToolBlock.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,77 @@ describe("ToolBlock output section", () => {
133133
expect(preview).not.toBeNull();
134134
expect(preview!.textContent).toBe("first line");
135135
});
136+
137+
it("renders history after expanding the tool block when result_events are set", async () => {
138+
const toolCall: ToolCall = {
139+
tool_name: "wait",
140+
category: "Other",
141+
result_content: "latest summary",
142+
result_events: [
143+
{
144+
source: "wait_output",
145+
status: "completed",
146+
content: "Finished successfully",
147+
content_length: 21,
148+
agent_id: "agent-1",
149+
event_index: 0,
150+
},
151+
],
152+
};
153+
component = mount(ToolBlock, {
154+
target: document.body,
155+
props: { content: "some input", toolCall },
156+
});
157+
await tick();
158+
159+
expect(document.querySelector(".history-header")).toBeNull();
160+
161+
document.querySelector<HTMLButtonElement>(".tool-header")!.click();
162+
await tick();
163+
164+
expect(document.querySelector(".history-header")).not.toBeNull();
165+
});
166+
167+
it("expands event history and shows chronological event content", async () => {
168+
const toolCall: ToolCall = {
169+
tool_name: "wait",
170+
category: "Other",
171+
result_content: "agent-a:\nFirst finished\n\nagent-b:\nSecond finished",
172+
result_events: [
173+
{
174+
source: "wait_output",
175+
status: "completed",
176+
content: "First finished",
177+
content_length: 14,
178+
agent_id: "agent-a",
179+
event_index: 0,
180+
},
181+
{
182+
source: "subagent_notification",
183+
status: "completed",
184+
content: "Second finished",
185+
content_length: 15,
186+
agent_id: "agent-b",
187+
event_index: 1,
188+
},
189+
],
190+
};
191+
component = mount(ToolBlock, {
192+
target: document.body,
193+
props: { content: "some input", toolCall },
194+
});
195+
await tick();
196+
197+
document.querySelector<HTMLButtonElement>(".tool-header")!.click();
198+
await tick();
199+
document.querySelector<HTMLButtonElement>(".history-header")!.click();
200+
await tick();
201+
202+
const historyEntries = Array.from(document.querySelectorAll(".history-content"));
203+
expect(historyEntries).toHaveLength(2);
204+
expect(historyEntries[0].textContent).toBe("First finished");
205+
expect(historyEntries[1].textContent).toBe("Second finished");
206+
});
136207
});
137208

138209
describe("ToolBlock fallback content", () => {

internal/db/db.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import (
2323
// formatting changes). Old databases with a lower user_version
2424
// trigger a non-destructive re-sync (mtime reset + skip cache
2525
// clear) so existing session data is preserved.
26-
const dataVersion = 6
26+
const dataVersion = 7
2727

2828
//go:embed schema.sql
2929
var schemaSQL string

0 commit comments

Comments
 (0)