Skip to content

Commit 4d8134a

Browse files
V48 Gate 3 (specification-implementation): relay raw live event_data so the telemetry log-line contract holds during streaming (F19)
The F19 contract (only LLM calls + Tool uses become rows) held on reload but NOT during a live synthesis: fragments (`/Users/.../uapi`, `setup`, `try`, `setup:asset-pack-clone-vcs-repository-agent`, commit hash, `bit-engine`, `main`, `setup-plan`, `custom_pipeline`, ...) still rendered as rows. Cause: usePipelineExecution has two paths. History relays raw `event_data` (the structured onStore event, namespace/key/executionState intact) — the activity-builder classifier keys off `namespace` to suppress fragments, so history was correct. The live SSE tail re-parsed each frame through parseStreamChunk, which flattened the event into a namespace-less `{type:'status', status:{message}}` shape; with the namespace stripped, the classifier's no-namespace-status rule kept every store as a fragment row. The live tail is the path active while a synthesis streams, so the fragments persisted on screen. Fix (usePipelineExecution.ts): the live tail now relays raw `event_data` verbatim (already source-safe filtered server-side), identical in shape to the history path — no parseStreamChunk flattening. work-update frames still feed recordWorkUpdate. parseStreamChunk import removed (now unused here). The classifier therefore sees namespace/key/executionState live and suppresses fragments while keeping llm/output (generation) + tool result/error rows with full hierarchy. Added a usePipelineExecution test asserting the live tail preserves namespace/key/executionState verbatim. Spec (formal log-line contract) + QA F19 updated to record that the contract depends on a consistent structured event shape live and on reload. uapi tsc 0; usePipelineExecution (3) + terminal/deposit/conversation/reading telemetry batch green; spec checker green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a2cd058 commit 4d8134a

4 files changed

Lines changed: 81 additions & 47 deletions

File tree

BITCODE_SPEC_V48_NOTES.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,12 @@ Accepted V48 architecture law (decided 2026-06-25):
217217
null-separated row key; the renderer displays the text before the separator,
218218
keys details by the full key, and bypasses de-dup for uniquely-keyed rows. This
219219
is what removes the `try`/`setup-plan`/`thricified-generation`/path fragments
220-
and stabilizes the pipeline↔UI payload contract.
220+
and stabilizes the pipeline↔UI payload contract. The contract depends on the
221+
client seeing the SAME structured event shape live as on reload: the live SSE
222+
tail (`usePipelineExecution`) therefore relays raw `event_data` verbatim
223+
(namespace/key/executionState intact) rather than re-parsing it — re-parsing
224+
flattened events into namespace-less status frames, which defeated the
225+
classifier and leaked every store as a fragment row during streaming.
221226
- **OTF (on-the-fly instructions) is eradicated entirely** as legacy residue:
222227
the dead telemetry types (`otf_instructions`/`otf_adherence`), the unused
223228
`setOnTheFly` prompt primitive, the live instruction-injection feature (the

BITCODE_V48_QA.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,8 @@ Track 3-4 scripts (BTD ledger, settlement, pack journaling) get added when those
199199
- Repair (2026-06-26, `terminal-run-activity.ts` + `pipeline-execution-log.tsx`):
200200
- The activity builder maintains a rolling `{phase, agent, step, failsafe, generation}` context updated from every event, and emits a row ONLY for LLM calls (`generation` / `llm:output`), Tool uses (`tool|tools:result|error`), and terminal/high-level signals (`completion`/`error`/no-namespace status). LLM rows stamp their own 5-field hierarchy; tool rows stamp Phase/Agent/Step from the rolling context + the tool name/args accumulated per tool-execution node.
201201
- Distinct calls that share withheld text are kept distinct via a unique null-separated row key (`TELEMETRY_ROW_KEY_SEP`); the renderer displays only the text before the separator, looks up details by the full key, and bypasses message de-dup for uniquely-keyed rows.
202-
- Verified: uapi tsc 0; F19 contract test (fragments suppressed, LLM row carries 5-pill hierarchy, tool row carries Phase/Agent/Step + tool) + 10-suite telemetry/terminal/deposit regression batch (38 tests) green.
202+
- `usePipelineExecution.ts` (the second half — fragments persisted live) — the live SSE tail re-parsed each frame through `parseStreamChunk`, which flattened the structured onStore event into a namespace-less `{type:'status', status:{message}}` shape; the activity-builder classifier keys off `namespace` to suppress fragments, so namespace-stripped live events leaked every store as a row (the history path, which relays raw `event_data`, was already correct). The tail now relays raw `event_data` verbatim — identical in shape to history — so the contract holds during streaming, not just on reload.
203+
- Verified: uapi tsc 0; F19 activity-builder contract test + a `usePipelineExecution` test asserting the live tail preserves `namespace`/`key`/`executionState` + 10-suite telemetry/terminal/deposit regression batch — all green.
203204

204205
## Track 1 — Identity / Authentication / Auxillaries — COMPLETE 2026-06-12 (email deferred by decision; F2/F9 and legacy eradication queued for gates)
205206

uapi/hooks/usePipelineExecution.ts

Lines changed: 21 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2-
import { parseStreamChunk } from '@/streaming/stream-parser';
32

43
interface PipelineExecution {
54
id: string;
@@ -82,60 +81,37 @@ export function usePipelineExecution(runId: string | null): UsePipelineExecution
8281
if (!reader) return;
8382
const decoder = new TextDecoder();
8483
let buffer = '';
84+
let liveSeq = 0;
8585
for (;;) {
8686
const { done, value } = await reader.read();
8787
if (done) break;
8888
buffer += decoder.decode(value, { stream: true });
8989
const parts = buffer.split('\n\n');
9090
buffer = parts.pop() || '';
9191
for (const chunk of parts) {
92-
const parsed = parseStreamChunk(chunk + '\n');
93-
if (parsed.type === 'work-update' && parsed.update) {
94-
recordWorkUpdate(parsed);
95-
setEvents(prev => prev.concat([{ id: `${Date.now()}:work-update`, event: parsed, created_at: new Date().toISOString() }]));
96-
continue;
97-
}
98-
if (parsed.status) {
99-
const ev = { type: 'status', status: parsed.status };
100-
setEvents(prev => prev.concat([{ id: `${Date.now()}:status`, event: ev, created_at: new Date().toISOString() }]));
101-
}
102-
if (parsed.text && !parsed.status && !parsed.error && !parsed.completion) {
103-
const ev = { type: 'message', message: parsed.text } as any;
104-
setEvents(prev => prev.concat([{ id: `${Date.now()}:msg`, event: ev, created_at: new Date().toISOString() }]));
105-
}
106-
if (parsed.error) {
107-
const ev = { type: 'error', message: parsed.error } as any;
108-
setEvents(prev => prev.concat([{ id: `${Date.now()}:err`, event: ev, created_at: new Date().toISOString() }]));
109-
}
110-
if ((parsed as any).type) {
111-
const ev = (parsed as any).event || parsed;
112-
if (ev?.type === 'work-update') {
113-
recordWorkUpdate(ev);
114-
setEvents(prev => prev.concat([{ id: `${Date.now()}:work-update`, event: ev, created_at: new Date().toISOString() }]));
115-
continue;
116-
}
117-
if (ev) {
118-
setEvents(prev => prev.concat([{ id: `${Date.now()}:ev`, event: ev, created_at: new Date().toISOString() }]));
119-
}
120-
}
92+
// The live SSE tail relays raw `event_data` — the structured onStore
93+
// event with namespace/key/executionState intact (already source-safe
94+
// filtered server-side). Push it verbatim, identical in shape to the
95+
// history path, so the activity builder classifies live events the
96+
// same way and the formal log-line contract (F19: only LLM calls +
97+
// Tool uses become rows) holds during streaming, not just on reload.
98+
// Re-parsing through parseStreamChunk here used to flatten events into
99+
// namespace-less `status`/`message` shapes, which leaked every
100+
// intermediate store as a fragment row.
121101
const lines = chunk.split('\n').filter((l) => l.startsWith('data: ')).map((l) => l.substring(6));
122102
for (const line of lines) {
103+
let payload: any;
123104
try {
124-
const payload = JSON.parse(line);
125-
if (payload?.type === 'work-update') {
126-
recordWorkUpdate(payload);
127-
}
128-
if (
129-
payload &&
130-
(payload.type === 'pipeline' ||
131-
payload.type === 'phase' ||
132-
payload.type === 'agent' ||
133-
payload.type === 'completion' ||
134-
payload.type === 'error')
135-
) {
136-
setEvents(prev => prev.concat([{ id: `${Date.now()}:ev`, event: payload, created_at: new Date().toISOString() }]));
137-
}
138-
} catch {}
105+
payload = JSON.parse(line);
106+
} catch {
107+
continue;
108+
}
109+
if (!payload || typeof payload !== 'object') continue;
110+
if (payload.type === 'work-update') {
111+
recordWorkUpdate(payload);
112+
}
113+
const createdAt = typeof payload.timestamp === 'string' ? payload.timestamp : new Date().toISOString();
114+
setEvents(prev => prev.concat([{ id: `live:${Date.now()}:${liveSeq++}`, event: payload, created_at: createdAt }]));
139115
}
140116
}
141117
}

uapi/tests/usePipelineExecution.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,58 @@ describe('usePipelineExecution', () => {
7272
expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe('/api/executions/history/r1');
7373
});
7474

75+
it('relays raw live event_data with namespace/key intact (F19 fragment fix)', async () => {
76+
const historyResponse = {
77+
run: { id: 'r3', user_id: 'user-1', created_at: new Date().toISOString(), items: [], context: {} },
78+
events: [],
79+
};
80+
81+
// A namespaced fragment store and an llm/output generation, exactly as the
82+
// server persists them. The live tail must push these verbatim (namespace
83+
// preserved) rather than flattening them through parseStreamChunk.
84+
const streamReader = {
85+
read: jest
86+
.fn()
87+
.mockResolvedValueOnce({
88+
done: false,
89+
value: encoder.encode(
90+
'data: {"type":"status","namespace":"step","key":"name","message":"try","executionState":{}}\n\n' +
91+
'data: {"type":"generation","namespace":"llm","key":"output","message":"[content withheld — source-safe]","executionState":{"phase":"setup","agent":"A","step":"plan","failsafe":"prepare","generation":"reason"}}\n\n',
92+
),
93+
})
94+
.mockResolvedValueOnce({ done: true, value: undefined }),
95+
};
96+
97+
global.fetch = jest.fn((request: RequestInfo) => {
98+
const url = typeof request === 'string' ? request : (request as Request)?.url ?? '';
99+
if (url.startsWith('/api/executions/history/')) {
100+
return Promise.resolve({ ok: true, json: async () => historyResponse } as any);
101+
}
102+
if (url.startsWith('/api/executions/stream')) {
103+
return Promise.resolve({
104+
ok: true,
105+
headers: { get: () => 'text/event-stream; charset=utf-8' },
106+
body: { getReader: () => streamReader },
107+
} as any);
108+
}
109+
throw new Error(`Unexpected fetch: ${url}`);
110+
}) as any;
111+
112+
let latest: any;
113+
render(<Harness runId="r3" onResult={(state) => (latest = state)} />);
114+
115+
await waitFor(() => expect(latest?.isLoading).toBe(false));
116+
await waitFor(() =>
117+
expect(latest.events.some((e: any) => e.event?.namespace === 'llm' && e.event?.key === 'output')).toBe(true),
118+
);
119+
const stepEvent = latest.events.find((e: any) => e.event?.namespace === 'step');
120+
const llmEvent = latest.events.find((e: any) => e.event?.namespace === 'llm');
121+
// Namespace/key survive — the activity builder can classify and suppress the
122+
// `step/name=try` fragment while keeping the llm/output as a formal row.
123+
expect(stepEvent?.event?.key).toBe('name');
124+
expect(llmEvent?.event?.executionState).toMatchObject({ phase: 'setup', step: 'plan', generation: 'reason' });
125+
});
126+
75127
it('handles history fetch failure gracefully', async () => {
76128
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 });
77129

0 commit comments

Comments
 (0)