From 1c2959bdbcaf49174d2cc1124c668871cc612c1d Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 04:52:35 +0000 Subject: [PATCH 01/12] =?UTF-8?q?feat(harness):=20E19=20attach=20handshake?= =?UTF-8?q?=20=E2=80=94=20Blob=20hydrate=20+=20stream=20attach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open/login/F5 on a running durable turn hydrates Blob then GET /api/turns/:runId/stream at startIndex=0 with this-run-window dedup. Hot resume (same JS heap already applied [0,C)) reconnects at C. C advances on POST and GET via the inline onEvent and rides persistTurn. Fixes #813 Refs #794 --- app/harness/HarnessHost.tsx | 88 +++++++- lib/harnessChat.test.ts | 419 ++++++++++++++++++++++++++++++++++++ lib/harnessChat.ts | 250 +++++++++++++++++++-- lib/turnApi.test.ts | 164 +++++++++++++- lib/turnApi.ts | 193 +++++++++++++++++ lib/turnAttach.test.ts | 253 ++++++++++++++++++++++ lib/turnAttach.ts | 238 ++++++++++++++++++++ 7 files changed, 1581 insertions(+), 24 deletions(-) create mode 100644 lib/turnAttach.test.ts create mode 100644 lib/turnAttach.ts diff --git a/app/harness/HarnessHost.tsx b/app/harness/HarnessHost.tsx index 7aa8ec4b..7df81bcd 100644 --- a/app/harness/HarnessHost.tsx +++ b/app/harness/HarnessHost.tsx @@ -13,6 +13,7 @@ import { import { resetHarnessImageSession } from '../../lib/harnessImages'; import { resetHarnessMathSession } from '../../lib/harnessMath'; import { decideDetach, shouldAbortReader, abortReasonFor, decideDetachPersist, putPreservedTurn, shouldApplyMintBind, shouldSetHostTurnNote } from '../../lib/detachTurn'; +import { decideAttachClass, type HeapApplied } from '../../lib/turnAttach'; import { HarnessBridge, HARNESS_PROTOCOL_VERSION, @@ -60,6 +61,9 @@ import HarnessLoading from './HarnessLoading'; type Phase = 'loading' | 'ready' | 'error'; +type RunPromptAttach = { runId: string; startIndex: number; dedup: boolean }; +type RunPromptOpts = { pushUser?: boolean; attach?: RunPromptAttach }; + type DvuiModule = { dvui: ( canvas: string | HTMLCanvasElement, @@ -199,6 +203,15 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { const pollRef = useRef(null); const abortRef = useRef(null); const inflightRef = useRef(false); + /** + * Plan #813 (E19) — SSE frames **this JS heap** applied for the current + * `turnRunId`. Null after F5 / adopt / switch (ring rebuilt from Blob). + * Hot resume reads this, never envelope `C`. + */ + const heapAppliedRef = useRef(null); + const runPromptRef = useRef<(prompt: string, opts?: RunPromptOpts) => Promise>( + async () => {}, + ); /** Bumped on detach so a late runPrompt persist cannot clobber a switched session. */ const turnEpochRef = useRef(0); /** @@ -309,6 +322,8 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { if (bridge) { hydrateRingWindow(bridge, merged, latestRingStart(merged.messages.length)); } + // Ring rebuilt from Blob/local — this heap has not applied the stream. + heapAppliedRef.current = null; }, [writeLocalSession, hydrateRingWindow], ); @@ -348,14 +363,31 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { }); }, []); + /** + * Plan #813 — cold attach after the ring was rebuilt from Blob/local + * (boot / adopt / switch-back). Always `startIndex=0` + dedup. No-ops when + * not `running` or a turn is already inflight. + */ + const kickColdAttach = useCallback(() => { + if (inflightRef.current) return; + const s = sessionRef.current; + if (s.turnStatus !== 'running' || !s.turnRunId) return; + heapAppliedRef.current = null; + void runPromptRef.current('', { + attach: { runId: s.turnRunId, startIndex: 0, dedup: true }, + }); + }, []); + /** Activate a session (canonical id) on local state + Wasm ring + URL + picker. */ const activateSession = useCallback( (next: SessionSnapshot) => { adoptCloudSession(next); setActiveSessionId(next.id); void refreshSessions(); + // Plan #813: F5/login/new tab/switch-back rebuilt the ring — cold attach. + queueMicrotask(kickColdAttach); }, - [adoptCloudSession, refreshSessions], + [adoptCloudSession, refreshSessions, kickColdAttach], ); const persist = useCallback( @@ -395,12 +427,13 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { }, []); const runPrompt = useCallback( - async (prompt: string, opts?: { pushUser?: boolean }) => { + async (prompt: string, opts?: RunPromptOpts) => { const bridge = bridgeRef.current; if (!bridge || inflightRef.current) return; + const attaching = opts?.attach != null; const modelId = bridge.getSelectedModel(); - if (!modelId) { + if (!attaching && !modelId) { setHostNote('No model selected — catalog empty, failed to load, or not granted.'); try { bridge.pushMessage( @@ -465,14 +498,15 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { signal: controller.signal, // Default false: Wasm already painted the user line in queueSubmitFromUi. // true when host snapped from a historical ring window before the turn. - pushUser: opts?.pushUser ?? false, - modelId, + pushUser: attaching ? false : opts?.pushUser ?? false, + ...(modelId ? { modelId } : {}), // Phase 2 (#627 / #625): persist every mid-turn session patch // (cwd change, sandbox switch) via the same persist callback the // turn-end path uses — local write + coalesced cloud PUT. // Adversarial #844: late patches after detach take decideDetachPersist // (never writeLocal onto a switched session; never PUT a Clear'd id). onSessionPatch: persistTurn, + ...(opts?.attach ? { attach: opts.attach } : {}), }, ); if (turnEpochRef.current !== epoch) { @@ -491,9 +525,44 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { // stream). Dropping session on signal.aborted left SessionStore behind Wasm: // Load earlier / refresh could wipe the cancelled turn from the ring. persistTurn(folded); + if (folded.turnStatus === 'running' && folded.turnRunId) { + heapAppliedRef.current = { + runId: folded.turnRunId, + count: folded.turnStreamCursor ?? 0, + }; + } else { + heapAppliedRef.current = null; + } if (!result.ok && shouldSetHostTurnNote(folded.turnStatus)) { setHostNote(result.error); } + // Plan #813: SSE drop while still mounted → hot resume at this-heap C. + // Empty-EOF GET (applied == startIndex) must not reconnect (spin). + // F5 is never this path (heapApplied was nulled; activateSession is cold). + if (folded.turnStatus === 'running' && folded.turnRunId) { + const applied = heapAppliedRef.current; + const attachStart = opts?.attach?.startIndex; + const progressed = + attachStart === undefined || + (applied != null && applied.count > attachStart); + const cls = decideAttachClass({ + turnRunId: folded.turnRunId, + turnStatus: folded.turnStatus, + envelopeCursor: folded.turnStreamCursor, + heapApplied: applied, + }); + if (cls.kind === 'hot' && progressed) { + queueMicrotask(() => { + void runPromptRef.current('', { + attach: { + runId: folded.turnRunId!, + startIndex: cls.startIndex, + dedup: false, + }, + }); + }); + } + } } finally { const detached = turnEpochRef.current !== epoch; const pendingId = pendingMintBindRef.current; @@ -530,6 +599,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { }, [persist, setUrlSessionId, writeLocalSession], ); + runPromptRef.current = runPrompt; useEffect(() => { let cancelled = false; @@ -561,6 +631,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { // re-checks getLocal, but this host guard is authoritative for the active id. if (snap.id !== sessionRef.current.id) return; adoptCloudSession(snap); + queueMicrotask(kickColdAttach); }, }); repoRef.current = repo; @@ -710,6 +781,12 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { // or refresh can recover — don't permanently strand local-only this page load. if (result.kind === 'local') setCloudEnabled(r.enabled); void refreshSessions(); + // Plan #813: after Blob/local hydrate, cold-attach a still-running + // turn. activateSession also kicks; inflightRef de-dupes the pair. + // Do not auto-attach completed sessions (`turnStatus !== 'running'`). + if (!cancelled) { + queueMicrotask(kickColdAttach); + } })(); const poll = () => { @@ -816,6 +893,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { setUrlSessionId, applySessionModel, foldPendingModelChange, + kickColdAttach, ]); /** diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 53e500fd..88c2f5bc 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -4493,3 +4493,422 @@ describe('runHarnessTurn durable-turn fold (plan #811 / D17)', () => { ); }); }); + +describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { + function runningSession( + messages: Array<[role: 'user' | 'assistant' | 'tool_run' | 'system' | 'skill_attached', text: string]> = [ + ['user', 'hello'], + ], + extra?: Partial>, + ) { + let s = createEmptySession('s_attach_1'); + for (const [role, text] of messages) { + s = appendMessage(s, role, text); + } + return { + ...s, + turnRunId: 'wr_live', + turnStatus: 'running' as const, + ...extra, + }; + } + + type AttachInit = { + sessionId: string; + startIndex?: number; + onEvent?: (event: AgentStreamEvent) => void | Promise; + onTurnStarted?: (info: { turnRunId: string }) => void | Promise; + }; + + it('test 2: hot resume at C grows the live assistant suffix, no duplicate, no ring clear', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.Assistant, 'Hello'); + const session = runningSession( + [ + ['user', 'hello'], + ['assistant', 'Hello'], + ], + { turnStreamCursor: 7 }, + ); + const startIndexes: number[] = []; + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_live', + startIndex: 7, + dedup: false, + attachStream: async (runId, opts: AttachInit) => { + startIndexes.push(opts.startIndex ?? 0); + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'text_delta', text: ' world' }); + await opts.onEvent?.({ type: 'done', text: 'Hello world' }); + return { ok: true, text: 'Hello world', turnRunId: runId }; + }, + }, + }); + expect(result.ok).toBe(true); + expect(startIndexes).toEqual([7]); + const assistants = exp.__messages.filter((m) => m.kind === MessageKind.Assistant); + expect(assistants.map((m) => m.text)).toEqual(['Hello world']); + expect(exp.__messages.filter((m) => m.kind === MessageKind.User).map((m) => m.text)).toEqual([ + 'hello', + ]); + expect(next.messages.filter((m) => m.role === 'assistant').map((m) => m.text)).toEqual([ + 'Hello world', + ]); + }); + + it('test 2b: F5/boot of originating tab with envelope C>0 attaches at startIndex=0 + dedup, never C', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + const session = runningSession([['user', 'hello']], { turnStreamCursor: 4096 }); + const startIndexes: number[] = []; + const { result } = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + startIndexes.push(opts.startIndex ?? 0); + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'reasoning_delta', text: 'hmm' }); + await opts.onEvent?.({ type: 'text_delta', text: 'Hi' }); + await opts.onEvent?.({ type: 'done', text: 'Hi' }); + return { ok: true, text: 'Hi', turnRunId: runId }; + }, + }, + }); + expect(result.ok).toBe(true); + expect(startIndexes).toEqual([0]); + expect(startIndexes).not.toContain(4096); + expect(exp.__messages.some((m) => m.kind === MessageKind.Thinking && m.text === 'hmm')).toBe( + true, + ); + expect(exp.__messages.filter((m) => m.kind === MessageKind.Assistant).map((m) => m.text)).toEqual( + ['Hi'], + ); + }); + + it('test 3: two cold consumers both render thinking + text once from startIndex=0 + dedup', async () => { + const events: AgentStreamEvent[] = [ + { type: 'reasoning_delta', text: 'plan' }, + { type: 'text_delta', text: 'Answer' }, + { type: 'done', text: 'Answer' }, + ]; + async function oneTab() { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + const session = runningSession([['user', 'hello']]); + const { result } = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + for (const ev of events) await opts.onEvent?.(ev); + return { ok: true, text: 'Answer', turnRunId: runId }; + }, + }, + }); + return { result, exp }; + } + const a = await oneTab(); + const b = await oneTab(); + expect(a.result.ok).toBe(true); + expect(b.result.ok).toBe(true); + for (const tab of [a, b]) { + expect(tab.exp.__messages.some((m) => m.kind === MessageKind.Thinking && m.text === 'plan')).toBe( + true, + ); + expect( + tab.exp.__messages.filter((m) => m.kind === MessageKind.Assistant).map((m) => m.text), + ).toEqual(['Answer']); + } + }); + + it('test 4: poison/absent C while running still GET-attaches at 0 (not hydrate-only)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + const session = runningSession([['user', 'hello']]); + delete session.turnStreamCursor; + const called: number[] = []; + await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + called.push(opts.startIndex ?? 0); + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'text_delta', text: 'ok' }); + await opts.onEvent?.({ type: 'done', text: 'ok' }); + return { ok: true, text: 'ok', turnRunId: runId }; + }, + }, + }); + expect(called).toEqual([0]); + }); + + it('test 5: attach to a completed producer replays at 0 + dedup, no cancel, not left Busy', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const end = describeTurnEnd('model'); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.Assistant, 'Hi'); + bridge.pushMessage(MessageKind.System, end); + const session = runningSession( + [ + ['user', 'hello'], + ['assistant', 'Hi'], + ['system', end], + ], + { turnStatus: 'completed', turnRunId: 'wr_done', turnStreamCursor: 0 }, + ); + const sendAgent = vi.fn(async () => { + throw new Error('must not cancel / POST'); + }); + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + sendAgent, + attach: { + runId: 'wr_done', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'text_delta', text: 'Hi' }); + await opts.onEvent?.({ type: 'done', text: 'Hi' }); + return { ok: true, text: 'Hi', turnRunId: runId }; + }, + }, + }); + expect(result.ok).toBe(true); + expect(sendAgent).not.toHaveBeenCalled(); + expect(next.turnStatus).toBe('completed'); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(exp.__messages.filter((m) => m.kind === MessageKind.Assistant).map((m) => m.text)).toEqual( + ['Hi'], + ); + expect(exp.__messages.filter((m) => m.text === end)).toHaveLength(1); + }); + + it('test 6: 404 attach paints EMBER and never calls /api/agent', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const session = runningSession(); + const sendAgent = vi.fn(async () => { + throw new Error('sendAgent /api/agent'); + }); + const sendAgentStream = vi.fn(async () => { + throw new Error('sendAgentStream /api/agent'); + }); + const fetchMock = vi.fn(async (url: string) => { + expect(String(url)).not.toMatch(/\/api\/agent/); + return new Response('nope', { status: 500 }); + }); + vi.stubGlobal('fetch', fetchMock); + try { + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + sendAgent, + sendAgentStream, + attach: { + runId: 'wr_gone', + startIndex: 0, + dedup: true, + attachStream: async () => ({ + ok: false as const, + status: 404, + error: 'Run not found: wr_gone', + turnRunId: 'wr_gone', + }), + }, + }); + expect(result.ok).toBe(false); + expect(sendAgent).not.toHaveBeenCalled(); + expect(sendAgentStream).not.toHaveBeenCalled(); + expect( + fetchMock.mock.calls.every((c) => !String(c[0]).includes('/api/agent')), + ).toBe(true); + expect(exp.__messages.some((m) => m.kind === MessageKind.Error && /Run not found/.test(m.text))).toBe( + true, + ); + expect(exp.__lifecycle()).toBe(Lifecycle.Error); + // Attach 404 is "could not subscribe", not "the turn died". + expect(next.turnStatus).toBe('running'); + expect(next.turnRunId).toBe('wr_gone'); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('test 6b: 503 attach paints EMBER and never calls /api/agent', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const session = runningSession(); + const sendAgent = vi.fn(async () => { + throw new Error('sendAgent /api/agent'); + }); + const sendAgentStream = vi.fn(async () => { + throw new Error('sendAgentStream /api/agent'); + }); + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + sendAgent, + sendAgentStream, + attach: { + runId: 'wr_1', + startIndex: 0, + dedup: true, + attachStream: async () => ({ + ok: false as const, + status: 503, + error: 'Unable to attach to run stream (store unavailable).', + turnRunId: 'wr_1', + }), + }, + }); + expect(result.ok).toBe(false); + expect(sendAgent).not.toHaveBeenCalled(); + expect(sendAgentStream).not.toHaveBeenCalled(); + expect( + exp.__messages.some( + (m) => m.kind === MessageKind.Error && /store unavailable/.test(m.text), + ), + ).toBe(true); + expect(exp.__lifecycle()).toBe(Lifecycle.Error); + expect(next.turnStatus).toBe('running'); + expect(next.turnRunId).toBe('wr_1'); + }); + + it('test 7: dedup skips hydrated this-run assistant/tool_run, never skips reasoning, prior-turn assistant is not a skip target', async () => { + const g = createToolRunGroup(); + addToolStart(g, 'read_file'); + addToolResult(g, 'read_file', true, 'ok', undefined); + const payload = encodeToolRun(g)!; + + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.ToolRun, payload); + bridge.pushMessage(MessageKind.Assistant, 'Hello'); + const session = runningSession([ + ['user', 'hello'], + ['tool_run', payload], + ['assistant', 'Hello'], + ]); + await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'tool_start', name: 'read_file' }); + await opts.onEvent?.({ + type: 'tool_result', + name: 'read_file', + ok: true, + summary: 'ok', + }); + await opts.onEvent?.({ type: 'reasoning_delta', text: 'think' }); + await opts.onEvent?.({ type: 'text_delta', text: 'Hello' }); + await opts.onEvent?.({ type: 'text_delta', text: ' world' }); + await opts.onEvent?.({ type: 'done', text: 'Hello world' }); + return { ok: true, text: 'Hello world', turnRunId: runId }; + }, + }, + }); + expect(exp.__messages.filter((m) => m.kind === MessageKind.ToolRun)).toHaveLength(1); + expect( + exp.__messages + .filter((m) => m.kind === MessageKind.Assistant) + .map((m) => m.text) + .join(''), + ).toBe('Hello world'); + expect(exp.__messages.some((m) => m.kind === MessageKind.Thinking && m.text === 'think')).toBe( + true, + ); + + const exp2 = makeMockExports(); + const bridge2 = new HarnessBridge(exp2); + bridge2.pushMessage(MessageKind.User, 'first'); + bridge2.pushMessage(MessageKind.Assistant, 'OLD'); + bridge2.pushMessage(MessageKind.User, 'second'); + let s2 = createEmptySession('s_attach_2'); + s2 = appendMessage(s2, 'user', 'first'); + s2 = appendMessage(s2, 'assistant', 'OLD'); + s2 = appendMessage(s2, 'user', 'second'); + s2 = { ...s2, turnRunId: 'wr_live', turnStatus: 'running' }; + await runHarnessTurn(bridge2, s2, '', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'text_delta', text: 'NEW' }); + await opts.onEvent?.({ type: 'done', text: 'NEW' }); + return { ok: true, text: 'NEW', turnRunId: runId }; + }, + }, + }); + expect( + exp2.__messages.filter((m) => m.kind === MessageKind.Assistant).map((m) => m.text), + ).toEqual(['OLD', 'NEW']); + }); + + it('test 8: attach EOF without done/error is detach, keep running, no Turn ended', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const session = runningSession(); + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'text_delta', text: 'partial' }); + return { ok: true, text: 'partial', turnRunId: runId }; + }, + }, + }); + expect(result.ok).toBe(false); + expect(next.turnRunId).toBe('wr_live'); + expect(next.turnStatus).toBe('running'); + expect(next.turnStreamCursor).toBe(1); + expect(next.messages.some((m) => m.role === 'system' && isTurnEndLine(m.text))).toBe(false); + expect(next.messages.some((m) => m.role === 'error' && isTurnEndLine(m.text))).toBe(false); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + }); + + it('test 9: POST D17 path advances C on each SSE frame; persistTurn sees C; complete zeros it', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const cursors: Array = []; + const { session: next } = await runHarnessTurn(bridge, createEmptySession('s1'), 'hi', { + streamAgent: true, + sendAgentStream: async (_prompt, init) => { + await init?.onTurnStarted?.({ turnRunId: 'wr_post' }); + await init?.onEvent?.({ type: 'text_delta', text: 'He' }); + await init?.onEvent?.({ type: 'text_delta', text: 'llo' }); + await init?.onEvent?.({ + type: 'usage', + usage: { source: 'provider', prompt: 1, completion: 2 }, + }); + await init?.onEvent?.({ type: 'done', text: 'Hello' }); + return { ok: true, text: 'Hello', turnRunId: 'wr_post' }; + }, + onSessionPatch: (s) => { + cursors.push(s.turnStreamCursor); + }, + }); + expect(cursors.some((c) => typeof c === 'number' && c > 0)).toBe(true); + expect(cursors).toContain(3); // 2 text_delta + usage, patched on usage + expect(next.turnStreamCursor).toBe(0); + expect(next.turnStatus).toBe('completed'); + }); +}); diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index af7b7ee0..42fe32c9 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -18,7 +18,7 @@ import { type SendAgentStreamFn, type ToolTraceEntry, } from './agentApi'; -import { sendTurn, sendTurnStream } from './turnApi'; +import { sendTurn, sendTurnStream, attachTurnStream } from './turnApi'; import { isDetachAbort } from './detachTurn'; import { type AgentStreamEvent } from './agent/agentStream'; import { @@ -31,8 +31,20 @@ import { import { isRedisSafeOpaqueId, normalizeSessionCwd, + sanitizeTurnStreamCursor, STATUS_SLOT_MAX_BYTES, } from './sessionCloudCaps'; +import { + bumpStreamCursor, + foldThisRunAssistant, + shouldSkipToolResult, + shouldSkipToolStart, + skillAlreadyHydrated, + textDeltaDedup, + thisRunAssistantText, + thisRunToolItems, + thisRunWindow, +} from './turnAttach'; import { SANDBOX_FORBIDDEN_ERROR, SANDBOX_SELECTION_REQUIRED_ERROR, @@ -279,6 +291,17 @@ export type RunHarnessTurnOptions = Omit & { * before `done`. Never awaited; wired to the existing `persist` callback. */ onSessionPatch?: (s: SessionSnapshot) => void; + /** + * Plan #813 (E19) — GET-attach an existing durable run instead of POST + * `/api/turns`. Skips prompt validation / user push. `dedup` enables the + * this-run-window skip (cold attach). Not `applyTurnEvent` (E20). + */ + attach?: { + runId: string; + startIndex: number; + dedup: boolean; + attachStream?: typeof attachTurnStream; + }; }; export type HarnessTurnResult = { @@ -973,19 +996,24 @@ export async function runHarnessTurn( rawPrompt: string, opts?: RunHarnessTurnOptions, ): Promise { - const validation = validatePrompt(rawPrompt); - if (validation) { - bridge.pushMessage(MessageKind.Error, describeTurnEnd('validation', validation)); - const next = appendMessage(session, 'error', describeTurnEnd('validation', validation)); - completeTurn(bridge, false); // validation — no auto-promote - return { result: { ok: false, error: validation }, session: next }; + const attaching = opts?.attach != null; + if (!attaching) { + const validation = validatePrompt(rawPrompt); + if (validation) { + bridge.pushMessage(MessageKind.Error, describeTurnEnd('validation', validation)); + const next = appendMessage(session, 'error', describeTurnEnd('validation', validation)); + completeTurn(bridge, false); // validation — no auto-promote + return { result: { ok: false, error: validation }, session: next }; + } } - const prompt = normalizePrompt(rawPrompt); - const withUser = appendMessage(session, 'user', prompt); + const prompt = attaching ? '' : normalizePrompt(rawPrompt); + const withUser = attaching ? session : appendMessage(session, 'user', prompt); // Wasm pending-submit path sets pushUser:false (user line already in canvas). - const pushUser = opts?.pushUser !== false; + // Attach (E19) never re-pushes the user line — the ring was hydrated (cold) + // or already has it (hot). + const pushUser = attaching ? false : opts?.pushUser !== false; // Always schedule user-body images/math (Wasm may already show the user line). scheduleImagesFromMarkdown(bridge, prompt); scheduleMathFromMarkdown(bridge, prompt); @@ -1069,6 +1097,28 @@ export async function runHarnessTurn( * false and the 5× loop is unchanged there. */ let streamPainted = false; + // Plan #813 (E19) — this-heap SSE-frame count. POST starts at 0; hot + // resume starts at attach.startIndex; cold starts at 0. Incremented per + // parsed event. Persisted only when onSessionPatch already fires (or at + // turn end) — never a new HTTP per token. + const attachOpts = opts?.attach; + const dedup = attachOpts?.dedup === true; + let heapC = attachOpts != null + ? (sanitizeTurnStreamCursor(attachOpts.startIndex) ?? 0) + : 0; + if (attaching && attachOpts) { + next = { + ...next, + turnRunId: attachOpts.runId, + turnStatus: next.turnStatus === 'completed' ? next.turnStatus : 'running', + turnStreamCursor: heapC, + }; + } + const hydratedAssistantStart = dedup ? thisRunAssistantText(next.messages) : ''; + let hydratedAssistant = hydratedAssistantStart; + const hydratedTools = dedup ? thisRunToolItems(next.messages) : []; + const replayedStarts: Record = {}; + const replayedResults: Record = {}; // Last confirmed-successful `change_dir` cwd this turn (phase 2 of #464 / // plan #465): recorded from live tool events (stream) or the JSON toolTrace, // applied on non-success terminals and as a success fallback so an aborted @@ -1094,9 +1144,48 @@ export async function runHarnessTurn( * host is the sole ring writer, so this single boolean is the only predicate * needed; `updateLastMessage`'s return stays as insurance, never a decision. */ - let lastRingRowIsToolRun = false; + let lastRingRowIsToolRun = attaching && lastUiKind === 'tool_run'; /** Session id of the current open live tool card (patched in place on growth). */ - let openToolRunId: string | null = null; + let openToolRunId: string | null = attaching && lastUiKind === 'tool_run' + ? (next.messages[next.messages.length - 1]?.id ?? null) + : null; + + if (attaching && lastUiKind === 'tool_run') { + const last = next.messages[next.messages.length - 1]; + if (last?.role === 'tool_run') { + const decoded = decodeToolRun(last.text); + if (decoded) { + toolRunGroup = { + items: decoded.items.map((it) => ({ ...it })), + detailEncUsed: 0, + }; + } + } + } + + // Hot resume (and cold hydrate) continue the last live ring row so a + // suffix `text_delta` / `reasoning_delta` grows in place instead of + // pushing a duplicate bubble. Cold attach still starts `assistantAcc` + // empty so this-run-window skip can rebuild the prefix. + if (attaching) { + const n = bridge.messageCount(); + if (n > 0) { + const lastRing = bridge.messageAt(n - 1); + if (lastRing?.kind === MessageKind.Assistant) { + assistantSegment = lastRing.text; + assistantSegmentOpen = true; + assistantStarted = true; + lastUiKind = 'assistant'; + lastRingRowIsToolRun = false; + if (!dedup) assistantAcc = lastRing.text; + } else if (lastRing?.kind === MessageKind.Thinking) { + thinkingSegment = lastRing.text; + thinkingSegmentOpen = true; + lastUiKind = 'thinking'; + lastRingRowIsToolRun = false; + } + } + } const resetLiveToolStreak = () => { lastRingRowIsToolRun = false; @@ -1391,6 +1480,10 @@ export async function runHarnessTurn( // becomes an AgentRetryError so the narrow classifier can map retryable vs // permanent from HTTP status + classifyTurnFailure kind. const onStreamEvent = async (ev: AgentStreamEvent) => { + // Plan #813: every parsed SSE frame advances this-heap C (including + // skipped-by-dedup). One producer write = one getReadable index. + heapC = bumpStreamCursor(heapC); + next = { ...next, turnStreamCursor: heapC }; // Fail-closed retry gate (plan #759 adversarial-review Major): any event // that PAINTS the ring past the user line arms `streamPainted`, so a // retryable-looking failure after it becomes permanent single-attempt @@ -1405,18 +1498,75 @@ export async function runHarnessTurn( streamPainted = true; } if (ev.type === 'tool_start' || ev.type === 'tool_result') { + if (ev.type === 'tool_start') { + replayedStarts[ev.name] = (replayedStarts[ev.name] ?? 0) + 1; + if ( + shouldSkipToolStart({ + enabled: dedup, + hydrated: hydratedTools, + name: ev.name, + replayedStartsOfName: replayedStarts[ev.name] ?? 1, + }) + ) { + return; + } + } else { + replayedResults[ev.name] = (replayedResults[ev.name] ?? 0) + 1; + if ( + shouldSkipToolResult({ + enabled: dedup, + hydrated: hydratedTools, + name: ev.name, + replayedResultsOfName: replayedResults[ev.name] ?? 1, + }) + ) { + return; + } + } handleToolEvent(ev); return; } if (ev.type === 'reasoning_delta') { + // Thinking is never in Blob — never skip, even on cold attach. growThinking(ev.text); return; } if (ev.type === 'text_delta') { + const d = textDeltaDedup({ + enabled: dedup, + hydratedAssistant, + replayedBefore: assistantAcc, + chunk: ev.text, + }); + if (d.action === 'skip') { + assistantAcc += ev.text; + // Hydrated this-run assistant already on the ring — do not + // finalize-push a duplicate at `done`. + if (hydratedAssistant) assistantStarted = true; + return; + } + if (d.action === 'grow-suffix') { + if (!assistantSegmentOpen && lastUiKind === 'assistant') { + assistantSegmentOpen = true; + assistantStarted = true; + const last = next.messages[next.messages.length - 1]; + assistantSegment = last?.role === 'assistant' ? last.text : hydratedAssistant; + } + assistantAcc = hydratedAssistant; + growAssistant(d.chunk); + hydratedAssistant = assistantAcc; + return; + } growAssistant(ev.text); return; } if (ev.type === 'skill_attached') { + if (dedup && skillAlreadyHydrated(next.messages, ev)) { + if (Array.isArray(ev.attachedSlugs)) { + next = { ...next, attachedSlugs: [...ev.attachedSlugs] }; + } + return; + } // Server sends skill_attached events at the START of the turn (before // the model). Push the display-only row live; it is a non-tool // separator for the tool-run predicate. @@ -1463,6 +1613,44 @@ export async function runHarnessTurn( try { agentResult = await withTransientRetry( async () => { + if (attachOpts) { + const attachFn = attachOpts.attachStream ?? attachTurnStream; + if (!sessionId) { + return { + ok: false as const, + status: 400, + error: 'sessionId is required.', + }; + } + const r = await attachFn(attachOpts.runId, { + sessionId, + startIndex: attachOpts.startIndex, + signal: opts?.signal, + onEvent: onStreamEvent, + onTurnStarted: async ({ turnRunId }) => { + sawDurableStart = true; + next = { + ...next, + turnRunId, + turnStatus: + next.turnStatus === 'completed' ? 'completed' : 'running', + turnStreamCursor: heapC, + }; + opts?.onSessionPatch?.(next); + }, + }); + if (!r.ok) { + const kind = classifyTurnFailure(r.error, r.status, opts?.signal).kind; + throw new AgentRetryError( + r.error, + r.status, + kind, + r.attachedSlugs, + r.turnRunId, + ); + } + return r; + } const r = streamAgent ? await sendAgentStreamFn(apiPrompt, { signal: opts?.signal, @@ -1474,7 +1662,12 @@ export async function runHarnessTurn( onEvent: onStreamEvent, onTurnStarted: async ({ turnRunId }) => { sawDurableStart = true; - next = { ...next, turnRunId, turnStatus: 'running' }; + next = { + ...next, + turnRunId, + turnStatus: 'running', + turnStreamCursor: heapC, + }; opts?.onSessionPatch?.(next); }, }) @@ -1510,7 +1703,7 @@ export async function runHarnessTurn( // push duplicate assistant bubbles. `classifyTurnRetry` still owns // the status/stop mapping for the clean (never-painted) cases. classify: (err) => - streamPainted || sawDurableStart + streamPainted || sawDurableStart || attaching ? { kind: 'permanent' } : classifyTurnRetry(err), }, @@ -1533,7 +1726,7 @@ export async function runHarnessTurn( ).kind : undefined; const durableIncomplete = - streamAgent && + (streamAgent || attaching) && sawDurableStart && !sawStreamTerminal && stopKind !== 'stop'; @@ -1584,14 +1777,21 @@ export async function runHarnessTurn( scheduleImagesFromMarkdown(bridge, agentResult.text); assistantAcc = agentResult.text; } - next = appendMessage(next, 'assistant', agentResult.text || assistantAcc); + next = attaching + ? foldThisRunAssistant(next, agentResult.text || assistantAcc) + : appendMessage(next, 'assistant', agentResult.text || assistantAcc); scheduleMathFromTexts( bridge, next.messages .filter((m) => m.role === 'user' || m.role === 'assistant') .map((m) => m.text), ); - next = pushTurnEnd(bridge, next, 'model'); + if ( + !attaching || + !thisRunWindow(next.messages).some((m) => isTurnEndLine(m.text)) + ) { + next = pushTurnEnd(bridge, next, 'model'); + } lastUiKind = 'system'; // Success-path cwd apply (parent #270 / phase 2): prefers the authoritative // `agentResult.cwd`. Sanitize + renormalize exactly like the send path @@ -1693,7 +1893,9 @@ export async function runHarnessTurn( } const partial = (assistantAcc || '').trim(); if (partial) { - failedSession = appendMessage(failedSession, 'assistant', partial); + failedSession = attaching + ? foldThisRunAssistant(failedSession, partial) + : appendMessage(failedSession, 'assistant', partial); } const fail = durableIncomplete ? { kind: 'detach' as const, detail: undefined as string | undefined } @@ -1765,6 +1967,18 @@ export async function runHarnessTurn( turnStatus: 'running', }; } + } else if (attaching && fail.kind !== 'stop') { + // GET attach 404/503/auth is "could not subscribe", not "the turn + // died". Keep the live run so a later boot/F5 can retry. Never a + // server cancel. In-canvas EMBER is the pushTurnEnd above. + const id = agentResult.turnRunId ?? failedSession.turnRunId; + if (id !== undefined) { + failedSession = { + ...failedSession, + turnRunId: id, + turnStatus: 'running', + }; + } } else if ( agentResult.turnRunId !== undefined || (fail.kind === 'stop' && failedSession.turnStatus === 'running') diff --git a/lib/turnApi.test.ts b/lib/turnApi.test.ts index 207de867..578e11eb 100644 --- a/lib/turnApi.test.ts +++ b/lib/turnApi.test.ts @@ -6,7 +6,7 @@ * `sessionId`/`personaId`/`cwd` on the body, JSON 4xx, SSE success + failure. */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { sendTurn, sendTurnStream } from './turnApi'; +import { attachTurnStream, sendTurn, sendTurnStream } from './turnApi'; function sseResponse(chunks: string[], header?: { 'x-workflow-run-id': string }): Response { const body = new ReadableStream({ @@ -295,3 +295,165 @@ describe('sendTurnStream (SSE path — production default)', () => { } }); }); + +describe('attachTurnStream (GET attach — plan #813 E19)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('GETs /api/turns/:runId/stream?sessionId=&startIndex= and dispatches onEvent', async () => { + const events: string[] = []; + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + expect(init?.method).toBe('GET'); + expect(String(url)).toBe( + '/api/turns/wr_attach/stream?sessionId=s_tab&startIndex=0', + ); + expect((init?.headers as Record).Accept).toBe( + 'text/event-stream', + ); + return sseResponse( + [ + 'data: {"type":"reasoning_delta","text":"hmm"}\n\n', + 'data: {"type":"text_delta","text":"Hi"}\n\n', + 'data: {"type":"done","text":"Hi"}\n\n', + ], + { 'x-workflow-run-id': 'wr_attach' }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + const started: string[] = []; + const result = await attachTurnStream('wr_attach', { + sessionId: 's_tab', + startIndex: 0, + onTurnStarted: ({ turnRunId }) => { + started.push(turnRunId); + }, + onEvent: async (ev) => { + events.push(ev.type); + }, + }); + expect(started).toEqual(['wr_attach']); + expect(events).toEqual(['reasoning_delta', 'text_delta', 'done']); + expect(result.ok).toBe(true); + if (result.ok) expect(result.text).toBe('Hi'); + }); + + it('hot resume passes startIndex=C on the query string', async () => { + const fetchMock = vi.fn(async (url: string) => { + expect(String(url)).toContain('startIndex=42'); + expect(String(url)).toContain('sessionId=s_hot'); + return sseResponse( + ['data: {"type":"text_delta","text":"tail"}\n\n', 'data: {"type":"done","text":"tail"}\n\n'], + { 'x-workflow-run-id': 'wr_hot' }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + const result = await attachTurnStream('wr_hot', { + sessionId: 's_hot', + startIndex: 42, + }); + expect(result.ok).toBe(true); + }); + + it('abort closes this reader only — GET, no cancel POST', async () => { + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.method).toBe('GET'); + throw new DOMException('aborted', 'AbortError'); + }); + vi.stubGlobal('fetch', fetchMock); + const result = await attachTurnStream('wr_1', { + sessionId: 's_1', + startIndex: 0, + signal: new AbortController().signal, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBe('Request cancelled.'); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(String(url)).toContain('/stream?'); + expect((init as RequestInit).method).toBe('GET'); + }); + + it('JSON 404 is a failure and does not fire onTurnStarted', async () => { + const started: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json( + { error: 'Run not found: wr_gone' }, + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + const result = await attachTurnStream('wr_gone', { + sessionId: 's_1', + startIndex: 0, + onTurnStarted: ({ turnRunId }) => { + started.push(turnRunId); + }, + }); + expect(started).toEqual([]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(404); + expect(result.error).toMatch(/Run not found/); + } + }); + + it('JSON 503 is a failure (store unavailable)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json( + { error: 'Unable to attach to run stream (store unavailable).' }, + { status: 503, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + const result = await attachTurnStream('wr_1', { + sessionId: 's_1', + startIndex: 0, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(503); + }); + + it('empty done.text on attach is ok (thinking-only / all-dedup)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + sseResponse( + [ + 'data: {"type":"reasoning_delta","text":"think"}\n\n', + 'data: {"type":"done","text":""}\n\n', + ], + { 'x-workflow-run-id': 'wr_think' }, + ), + ), + ); + const result = await attachTurnStream('wr_think', { + sessionId: 's_1', + startIndex: 0, + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.text).toBe(''); + }); + + it('invalid startIndex / sessionId fail closed before fetch', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const badIndex = await attachTurnStream('wr_1', { + sessionId: 's_1', + startIndex: -1, + }); + expect(badIndex.ok).toBe(false); + if (!badIndex.ok) expect(badIndex.status).toBe(400); + const badSession = await attachTurnStream('wr_1', { + sessionId: 'not opaque!', + startIndex: 0, + }); + expect(badSession.ok).toBe(false); + if (!badSession.ok) expect(badSession.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/turnApi.ts b/lib/turnApi.ts index 738cad8b..d6c59670 100644 --- a/lib/turnApi.ts +++ b/lib/turnApi.ts @@ -1,5 +1,6 @@ /** * Plan #811 (D17) — host client for POST /api/turns (durable-turn transport). + * Plan #813 (E19) — GET attach client `attachTurnStream`. * Replaces the legacy `/api/agent` transport for production `runPrompt`. * `/api/agent` stays reachable via the legacy `sendAgent`/`sendAgentStream` * exports — tests inject those via `RunHarnessTurnOptions`. @@ -16,11 +17,17 @@ import { parseJsonAgentBody, parseToolTrace, type AgentFailure, + type AgentResult, type SendAgentFn, type SendAgentStreamFn, } from './agentApi'; import { sanitizeUsageSummary, type UsageSummary } from './agent/usageSummary'; import { readAgentStream, type AgentStreamResult } from './agentSse'; +import { + isRedisSafeOpaqueId, + sanitizeTurnRunId, + sanitizeTurnStreamCursor, +} from './sessionCloudCaps'; /** * Parse the `x-workflow-run-id` response header. @@ -354,3 +361,189 @@ export const sendTurnStream: SendAgentStreamFn = async (prompt, init) => { ...(turnWarning !== undefined ? { turnWarning } : {}), }; }; + +export type AttachTurnStreamOpts = { + sessionId: string; + startIndex?: number; + signal?: AbortSignal; + onEvent?: (event: AgentStreamEvent) => void | Promise; + /** + * Fired when the GET returns a 200 SSE body. The run id is the path param + * (already known); this marks “we actually opened a readable” so 4xx JSON + * is not classified as durable-incomplete detach. + */ + onTurnStarted?: (info: { turnRunId: string }) => void | Promise; +}; + +/** + * Plan #813 (E19) — GET `/api/turns/:runId/stream?sessionId=&startIndex=`. + * Reuses `readAgentStream`. Abort closes **this reader only** (D18: never a + * server cancel). Empty `done.text` is OK (cold replay may be all-dedup / + * thinking-only / still-running). + */ +export async function attachTurnStream( + runId: string, + opts: AttachTurnStreamOpts, +): Promise { + const cleanRunId = sanitizeTurnRunId(runId); + if (cleanRunId === undefined) { + return { ok: false, status: 400, error: 'Invalid runId' }; + } + if (!isRedisSafeOpaqueId(opts.sessionId)) { + return { ok: false, status: 400, error: 'Invalid sessionId.' }; + } + const rawIndex = opts.startIndex ?? 0; + const startIndex = sanitizeTurnStreamCursor(rawIndex); + if (startIndex === undefined) { + return { ok: false, status: 400, error: 'Invalid startIndex' }; + } + + const params = new URLSearchParams(); + params.set('sessionId', opts.sessionId); + params.set('startIndex', String(startIndex)); + const path = `/api/turns/${encodeURIComponent(cleanRunId)}/stream?${params.toString()}`; + + let res: Response; + try { + res = await fetch(path, { + method: 'GET', + headers: { Accept: AGENT_STREAM_ACCEPT }, + signal: opts.signal, + }); + } catch (err) { + if (isAbortError(err)) return cancelledFailure(); + return { + ok: false, + error: err instanceof Error ? err.message : 'Network request failed.', + }; + } + + const headerRunId = parseTurnRunId(res) ?? cleanRunId; + const turnWarning = parseTurnWarning(res); + const contentType = res.headers.get('content-type') ?? ''; + + if ( + contentType.includes('application/json') || + !contentType.includes('text/event-stream') + ) { + if (contentType.includes('application/json')) { + let data: unknown = null; + try { + data = await res.json(); + } catch (err) { + if (isAbortError(err)) return cancelledFailure({ turnRunId: headerRunId, turnWarning }); + data = null; + } + const result = parseJsonAgentBody(res, data); + if (!result.ok) { + return { + ...result, + turnRunId: headerRunId, + ...(turnWarning !== undefined ? { turnWarning } : {}), + }; + } + return { + ...result, + turnRunId: headerRunId, + ...(turnWarning !== undefined ? { turnWarning } : {}), + }; + } + let text = ''; + try { + text = await res.text(); + } catch (err) { + if (isAbortError(err)) return cancelledFailure({ turnRunId: headerRunId, turnWarning }); + text = ''; + } + return { + ok: false, + status: res.status, + error: text.trim() || `Request failed (${res.status}).`, + turnRunId: headerRunId, + ...(turnWarning !== undefined ? { turnWarning } : {}), + }; + } + + if (!res.ok) { + return { + ok: false, + status: res.status, + error: `Request failed (${res.status}).`, + turnRunId: headerRunId, + ...(turnWarning !== undefined ? { turnWarning } : {}), + }; + } + + if (!res.body) { + return { + ok: false, + status: res.status, + error: 'Empty stream body.', + turnRunId: headerRunId, + ...(turnWarning !== undefined ? { turnWarning } : {}), + }; + } + + try { + await opts.onTurnStarted?.({ turnRunId: headerRunId }); + } catch { + // Fold is best-effort. + } + + const reader = res.body.getReader(); + let streamUsage: UsageSummary | undefined; + let streamResult: AgentStreamResult; + try { + streamResult = await readAgentStream(reader, async (ev) => { + if (opts.onEvent) await opts.onEvent(ev); + if (ev.type === 'usage') { + streamUsage = sanitizeUsageSummary(ev.usage) ?? streamUsage; + } + }); + } catch (err) { + if (isAbortError(err)) { + return cancelledFailure({ turnRunId: headerRunId, turnWarning }); + } + return { + ok: false, + error: err instanceof Error ? err.message : 'Stream read failed.', + turnRunId: headerRunId, + ...(turnWarning !== undefined ? { turnWarning } : {}), + }; + } + + if (streamResult.error) { + return { + ok: false, + error: streamResult.error.error, + ...(streamResult.error.status !== undefined + ? { status: streamResult.error.status } + : {}), + turnRunId: headerRunId, + ...(turnWarning !== undefined ? { turnWarning } : {}), + }; + } + + const finalText = streamResult.finalText; + const toolTrace = parseToolTrace(streamResult.toolTraceRaw); + const doneUsage = sanitizeUsageSummary(streamResult.usageRaw); + const usage = doneUsage ?? streamUsage; + + // Attach may legitimately have empty text (thinking-only, all-dedup, or a + // still-running producer that EOFs). Do not map that to "Empty model response." + return { + ok: true, + text: (finalText ?? '').trim(), + ...(toolTrace ? { toolTrace } : {}), + ...(streamResult.cwd !== undefined ? { cwd: streamResult.cwd } : {}), + ...(streamResult.sandboxId !== undefined + ? { sandboxId: streamResult.sandboxId } + : {}), + ...(streamResult.activeSandboxId !== undefined + ? { activeSandboxId: streamResult.activeSandboxId } + : {}), + ...(usage ? { usage } : {}), + turnRunId: headerRunId, + ...(turnWarning !== undefined ? { turnWarning } : {}), + }; +} diff --git a/lib/turnAttach.test.ts b/lib/turnAttach.test.ts new file mode 100644 index 00000000..6188c74c --- /dev/null +++ b/lib/turnAttach.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest'; +import { encodeToolRun, addToolStart, addToolResult, createToolRunGroup } from './toolRun'; +import { appendMessage, createEmptySession, makeMessage } from './sessionStore'; +import { + bumpStreamCursor, + decideAttachClass, + shouldSkipToolResult, + shouldSkipToolStart, + skillAlreadyHydrated, + textDeltaDedup, + thisRunAssistantText, + thisRunToolItems, + thisRunWindow, +} from './turnAttach'; +import { TURN_STREAM_CURSOR_MAX } from './sessionCloudCaps'; + +describe('decideAttachClass', () => { + it('none when not running / no run id', () => { + expect( + decideAttachClass({ + turnStatus: 'completed', + turnRunId: 'wr_1', + heapApplied: { runId: 'wr_1', count: 3 }, + }), + ).toEqual({ kind: 'none' }); + expect( + decideAttachClass({ + turnStatus: 'running', + heapApplied: null, + }), + ).toEqual({ kind: 'none' }); + }); + + it('F5 / boot (no heap applied) is cold at 0 even when envelope C is large', () => { + expect( + decideAttachClass({ + turnRunId: 'wr_1', + turnStatus: 'running', + envelopeCursor: 4096, + heapApplied: null, + }), + ).toEqual({ kind: 'cold', startIndex: 0 }); + }); + + it('poison/absent envelope C while running is still cold when heap is empty', () => { + expect( + decideAttachClass({ + turnRunId: 'wr_1', + turnStatus: 'running', + envelopeCursor: undefined, + heapApplied: null, + }), + ).toEqual({ kind: 'cold', startIndex: 0 }); + }); + + it('C=0 with same-heap applied 0 is hot resume (not poison)', () => { + expect( + decideAttachClass({ + turnRunId: 'wr_1', + turnStatus: 'running', + envelopeCursor: 0, + heapApplied: { runId: 'wr_1', count: 0 }, + }), + ).toEqual({ kind: 'hot', startIndex: 0 }); + }); + + it('hot resume uses heap-applied count, not envelope C', () => { + expect( + decideAttachClass({ + turnRunId: 'wr_1', + turnStatus: 'running', + envelopeCursor: 12, + heapApplied: { runId: 'wr_1', count: 7 }, + }), + ).toEqual({ kind: 'hot', startIndex: 7 }); + }); + + it('same-heap hot resume ignores envelope C (other-tab LWW must not skip reconnect)', () => { + expect( + decideAttachClass({ + turnRunId: 'wr_1', + turnStatus: 'running', + envelopeCursor: 80, + heapApplied: { runId: 'wr_1', count: 4 }, + }), + ).toEqual({ kind: 'hot', startIndex: 4 }); + }); + + it('a different run id on the heap is cold (switch-back)', () => { + expect( + decideAttachClass({ + turnRunId: 'wr_new', + turnStatus: 'running', + envelopeCursor: 2, + heapApplied: { runId: 'wr_old', count: 9 }, + }), + ).toEqual({ kind: 'cold', startIndex: 0 }); + }); +}); + +describe('thisRunWindow / assistant text', () => { + it('scopes after the last user row — prior-turn assistant is not in the window', () => { + let s = createEmptySession(); + s = appendMessage(s, 'user', 'first'); + s = appendMessage(s, 'assistant', 'OLD'); + s = appendMessage(s, 'user', 'second'); + s = appendMessage(s, 'assistant', 'NEW'); + const w = thisRunWindow(s.messages); + expect(w.map((m) => m.role + ':' + m.text)).toEqual(['assistant:NEW']); + expect(thisRunAssistantText(s.messages)).toBe('NEW'); + }); +}); + +describe('textDeltaDedup', () => { + it('disabled → always grow the chunk', () => { + expect( + textDeltaDedup({ + enabled: false, + hydratedAssistant: 'Hello', + replayedBefore: '', + chunk: 'He', + }), + ).toEqual({ action: 'grow', chunk: 'He' }); + }); + + it('prefix of hydrated this-run assistant → skip (no double-grow)', () => { + expect( + textDeltaDedup({ + enabled: true, + hydratedAssistant: 'Hello world', + replayedBefore: '', + chunk: 'Hello', + }), + ).toEqual({ action: 'skip' }); + expect( + textDeltaDedup({ + enabled: true, + hydratedAssistant: 'Hello world', + replayedBefore: 'Hello', + chunk: ' world', + }), + ).toEqual({ action: 'skip' }); + }); + + it('extends hydrated text → grow only the suffix', () => { + expect( + textDeltaDedup({ + enabled: true, + hydratedAssistant: 'Hello', + replayedBefore: 'Hello', + chunk: ' world', + }), + ).toEqual({ action: 'grow-suffix', chunk: ' world' }); + }); + + it('no this-run assistant → grow (unpersisted live text)', () => { + expect( + textDeltaDedup({ + enabled: true, + hydratedAssistant: '', + replayedBefore: '', + chunk: 'live', + }), + ).toEqual({ action: 'grow', chunk: 'live' }); + }); +}); + +describe('tool ordinal skip', () => { + it('skips start/result when the hydrated card already has a terminal item', () => { + const g = createToolRunGroup(); + addToolStart(g, 'read_file'); + addToolResult(g, 'read_file', true, 'ok', undefined); + const payload = encodeToolRun(g); + expect(payload).toBeTruthy(); + let s = createEmptySession(); + s = appendMessage(s, 'user', 'read it'); + s = { + ...s, + messages: [...s.messages, makeMessage('tool_run', payload!)], + }; + const hydrated = thisRunToolItems(s.messages); + expect(hydrated).toEqual([{ name: 'read_file', status: 'ok' }]); + expect( + shouldSkipToolStart({ + enabled: true, + hydrated, + name: 'read_file', + replayedStartsOfName: 1, + }), + ).toBe(true); + expect( + shouldSkipToolResult({ + enabled: true, + hydrated, + name: 'read_file', + replayedResultsOfName: 1, + }), + ).toBe(true); + }); + + it('does not skip a live suffix tool that hydrate does not have', () => { + expect( + shouldSkipToolStart({ + enabled: true, + hydrated: [{ name: 'read_file', status: 'ok' }], + name: 'exec', + replayedStartsOfName: 1, + }), + ).toBe(false); + }); + + it('running hydrated card still grows on tool_result', () => { + expect( + shouldSkipToolResult({ + enabled: true, + hydrated: [{ name: 'exec', status: 'running' }], + name: 'exec', + replayedResultsOfName: 1, + }), + ).toBe(false); + }); +}); + +describe('skillAlreadyHydrated', () => { + it('skips a replayed skill row already in the this-run window', () => { + let s = createEmptySession(); + s = appendMessage(s, 'user', 'go'); + s = appendMessage(s, 'skill_attached', 'Skill attached: foo'); + expect( + skillAlreadyHydrated(s.messages, { + type: 'skill_attached', + action: 'attach', + slug: 'foo', + ok: true, + }), + ).toBe(true); + expect( + skillAlreadyHydrated(s.messages, { + type: 'skill_attached', + action: 'attach', + slug: 'bar', + ok: true, + }), + ).toBe(false); + }); +}); + +describe('bumpStreamCursor', () => { + it('increments and stays at the A3 max (does not drop-to-unset)', () => { + expect(bumpStreamCursor(0)).toBe(1); + expect(bumpStreamCursor(TURN_STREAM_CURSOR_MAX)).toBe(TURN_STREAM_CURSOR_MAX); + }); +}); diff --git a/lib/turnAttach.ts b/lib/turnAttach.ts new file mode 100644 index 00000000..1f945b81 --- /dev/null +++ b/lib/turnAttach.ts @@ -0,0 +1,238 @@ +/** + * Plan #813 (E19) — attach-handshake helpers (classify + this-run-window dedup). + * + * Pure + unit-testable. Not `applyTurnEvent` (that is E20 #814). The host + * GET client lives in `turnApi.attachTurnStream`; live grow stays inline in + * `runHarnessTurn`. + * + * Caps: none added or changed. Reuses `TURN_STREAM_CURSOR_MAX` / A3 sanitize. + */ +import type { AgentStreamEvent } from './agent/agentStream'; +import type { SessionMessage, SessionSnapshot } from './sessionStore'; +import { appendMessage } from './sessionStore'; +import { decodeToolRun } from './toolRun'; +import { + sanitizeTurnStreamCursor, + type TurnStatus, +} from './sessionCloudCaps'; + +export type HeapApplied = { runId: string; count: number }; + +export type AttachDecision = + | { kind: 'none' } + | { kind: 'hot'; startIndex: number } + | { kind: 'cold'; startIndex: 0 }; + +/** + * Classify hot resume vs cold attach by **this heap's ring**, not envelope `C`. + * + * - No live run → none (do not attach completed sessions on boot). + * - Heap has not applied this `turnRunId` (F5 / login / new tab / switch) → cold + * at `startIndex=0`, even if envelope `C` is large. + * - Same-heap live ring → hot resume at **heap-applied** count. Envelope `C` is + * a persist hint, not a skip: another tab's LWW write must not force a cold + * replay (or skip reconnect) while this ring already has `[0, applied)`. + */ +export function decideAttachClass(input: { + turnRunId?: string; + turnStatus?: TurnStatus; + envelopeCursor?: number; + heapApplied: HeapApplied | null; +}): AttachDecision { + if (input.turnStatus !== 'running' || !input.turnRunId) { + return { kind: 'none' }; + } + const heap = input.heapApplied; + const sameRun = heap != null && heap.runId === input.turnRunId; + if (!sameRun) { + return { kind: 'cold', startIndex: 0 }; + } + const startIndex = sanitizeTurnStreamCursor(heap.count) ?? 0; + return { kind: 'hot', startIndex }; +} + +/** + * Messages after the last `user` row — the prompt that started this `turnRunId`. + * Historical assistant / tool_run / skill_attached before that line are never + * skip targets. + */ +export function thisRunWindow(messages: SessionMessage[]): SessionMessage[] { + let lastUser = -1; + for (let i = 0; i < messages.length; i++) { + if (messages[i]?.role === 'user') lastUser = i; + } + if (lastUser < 0) return []; + return messages.slice(lastUser + 1); +} + +export function thisRunAssistantText(messages: SessionMessage[]): string { + let acc = ''; + for (const m of thisRunWindow(messages)) { + if (m.role === 'assistant') acc += m.text; + } + return acc; +} + +export type TextDedupAction = + | { action: 'grow'; chunk: string } + | { action: 'skip' } + | { action: 'grow-suffix'; chunk: string }; + +/** + * Cold-attach `text_delta` rule. `reasoning_delta` is never skipped (caller). + * Hydrated assistant is the this-run window only — a prior-turn assistant is + * not a skip target (`thisRunAssistantText` already scoped). + */ +export function textDeltaDedup(opts: { + enabled: boolean; + hydratedAssistant: string; + replayedBefore: string; + chunk: string; +}): TextDedupAction { + if (!opts.enabled) return { action: 'grow', chunk: opts.chunk }; + const replayed = opts.replayedBefore + opts.chunk; + const hydrated = opts.hydratedAssistant; + if (!hydrated) return { action: 'grow', chunk: opts.chunk }; + if (replayed === hydrated || hydrated.startsWith(replayed)) { + return { action: 'skip' }; + } + if (replayed.startsWith(hydrated)) { + const suffix = replayed.slice(hydrated.length); + if (!suffix) return { action: 'skip' }; + return { action: 'grow-suffix', chunk: suffix }; + } + return { action: 'grow', chunk: opts.chunk }; +} + +export type HydratedToolItem = { name: string; status: 'running' | 'ok' | 'fail' }; + +/** Flatten this-run `tool_run` cards into ordinal items (name + status). */ +export function thisRunToolItems(messages: SessionMessage[]): HydratedToolItem[] { + const out: HydratedToolItem[] = []; + for (const m of thisRunWindow(messages)) { + if (m.role !== 'tool_run') continue; + const decoded = decodeToolRun(m.text); + if (!decoded) continue; + for (const it of decoded.items) { + out.push({ name: it.name, status: it.status }); + } + } + return out; +} + +/** + * 1-based ordinal of `name` among items `[0, seenCount)` plus this event. + * `seenCount` is how many tool events (start or result, caller picks) of this + * name have already been replayed. + */ +function ordinalOf( + items: HydratedToolItem[], + name: string, + replayedOfName: number, +): HydratedToolItem | undefined { + let n = 0; + for (const it of items) { + if (it.name !== name) continue; + n += 1; + if (n === replayedOfName) return it; + } + return undefined; +} + +/** + * Skip re-push of a `tool_start` when this-run hydrate already has that call. + * Still apply when the ordinal is missing (live suffix). + */ +export function shouldSkipToolStart(opts: { + enabled: boolean; + hydrated: HydratedToolItem[]; + name: string; + /** 1-based count of `tool_start`s for `name` including this event. */ + replayedStartsOfName: number; +}): boolean { + if (!opts.enabled) return false; + const hit = ordinalOf(opts.hydrated, opts.name, opts.replayedStartsOfName); + return hit !== undefined; +} + +/** + * Skip a `tool_result` only when the hydrated ordinal is already terminal. + * A hydrated `running` card must still grow on the result. + */ +export function shouldSkipToolResult(opts: { + enabled: boolean; + hydrated: HydratedToolItem[]; + name: string; + /** 1-based count of `tool_result`s for `name` including this event. */ + replayedResultsOfName: number; +}): boolean { + if (!opts.enabled) return false; + const hit = ordinalOf(opts.hydrated, opts.name, opts.replayedResultsOfName); + if (!hit) return false; + return hit.status === 'ok' || hit.status === 'fail'; +} + +/** Mirror of `skillRowText` — kept here so this module does not import harnessChat. */ +function skillRowNeedle(ev: { + action: 'attach' | 'detach'; + slug: string; + ok: boolean; +}): string { + if (ev.action === 'detach') { + return ev.ok ? `Skill detached: ${ev.slug}` : `Skill not attached: ${ev.slug}`; + } + return ev.ok ? `Skill attached: ${ev.slug}` : `Skill not attached: ${ev.slug}`; +} + +export function skillAlreadyHydrated( + messages: SessionMessage[], + ev: Extract, +): boolean { + const text = skillRowNeedle(ev); + for (const m of thisRunWindow(messages)) { + if (m.role === 'skill_attached' && m.text === text) return true; + } + return false; +} + +/** + * Increment this-heap SSE-frame count. Stays at the A3 max rather than + * drop-to-unset (unset would look like poison and force a cold full replay). + */ +export function bumpStreamCursor(current: number): number { + const next = current + 1; + return sanitizeTurnStreamCursor(next) ?? current; +} + +/** + * Fold this-run assistant text into the session without duplicating a + * hydrated row. Extends the last assistant when `text` grows the hydrate + * prefix; no-ops when `text` is already present. + */ +export function foldThisRunAssistant( + session: SessionSnapshot, + text: string, +): SessionSnapshot { + const trimmed = (text ?? '').trim(); + if (!trimmed) return session; + const have = thisRunAssistantText(session.messages); + if (!have) { + return appendMessage(session, 'assistant', trimmed); + } + if (have === trimmed || have.startsWith(trimmed)) { + return session; + } + if (trimmed.startsWith(have)) { + let lastA = -1; + for (let i = 0; i < session.messages.length; i++) { + if (session.messages[i]?.role === 'assistant') lastA = i; + } + if (lastA < 0) return appendMessage(session, 'assistant', trimmed); + const msgs = session.messages.slice(); + const prev = msgs[lastA]!; + msgs[lastA] = { ...prev, text: trimmed }; + return { ...session, messages: msgs, updatedAt: Date.now() }; + } + return session; +} + From 11c1a6b80f8eec1e08ca725001e8297d767798e7 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 06:42:11 +0000 Subject: [PATCH 02/12] fix(harness): split attach 404 vs 503 fail contracts Adversarial #857 Major: attach HTTP failure no longer mixes give-up (Turn ended + Error lifecycle) with detach persist (keep running). 404 run-gone paints Turn ended, lands Error, and clears running so a later Send is not C15-409'd. 503/401/network keep running, land Ready, and paint a non-terminal EMBER row (no Turn ended). Host hot-resume goes through decideHotResume; source-lock covers kickColdAttach. --- app/harness/HarnessHost.tsx | 16 +++---- lib/harnessChat.test.ts | 71 ++++++++++++++++++++++++++-- lib/harnessChat.ts | 40 ++++++++++------ lib/turnAttach.test.ts | 94 +++++++++++++++++++++++++++++++++++++ lib/turnAttach.ts | 35 ++++++++++++++ 5 files changed, 227 insertions(+), 29 deletions(-) diff --git a/app/harness/HarnessHost.tsx b/app/harness/HarnessHost.tsx index 7df81bcd..d35c21d2 100644 --- a/app/harness/HarnessHost.tsx +++ b/app/harness/HarnessHost.tsx @@ -13,7 +13,7 @@ import { import { resetHarnessImageSession } from '../../lib/harnessImages'; import { resetHarnessMathSession } from '../../lib/harnessMath'; import { decideDetach, shouldAbortReader, abortReasonFor, decideDetachPersist, putPreservedTurn, shouldApplyMintBind, shouldSetHostTurnNote } from '../../lib/detachTurn'; -import { decideAttachClass, type HeapApplied } from '../../lib/turnAttach'; +import { decideHotResume, type HeapApplied } from '../../lib/turnAttach'; import { HarnessBridge, HARNESS_PROTOCOL_VERSION, @@ -540,23 +540,19 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { // Empty-EOF GET (applied == startIndex) must not reconnect (spin). // F5 is never this path (heapApplied was nulled; activateSession is cold). if (folded.turnStatus === 'running' && folded.turnRunId) { - const applied = heapAppliedRef.current; - const attachStart = opts?.attach?.startIndex; - const progressed = - attachStart === undefined || - (applied != null && applied.count > attachStart); - const cls = decideAttachClass({ + const resume = decideHotResume({ turnRunId: folded.turnRunId, turnStatus: folded.turnStatus, envelopeCursor: folded.turnStreamCursor, - heapApplied: applied, + heapApplied: heapAppliedRef.current, + attachStart: opts?.attach?.startIndex, }); - if (cls.kind === 'hot' && progressed) { + if (resume.kind === 'hot') { queueMicrotask(() => { void runPromptRef.current('', { attach: { runId: folded.turnRunId!, - startIndex: cls.startIndex, + startIndex: resume.startIndex, dedup: false, }, }); diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 88c2f5bc..fec6ea70 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -4736,10 +4736,13 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { expect(exp.__messages.some((m) => m.kind === MessageKind.Error && /Run not found/.test(m.text))).toBe( true, ); + expect( + exp.__messages.some((m) => m.kind === MessageKind.Error && isTurnEndLine(m.text)), + ).toBe(true); expect(exp.__lifecycle()).toBe(Lifecycle.Error); - // Attach 404 is "could not subscribe", not "the turn died". - expect(next.turnStatus).toBe('running'); - expect(next.turnRunId).toBe('wr_gone'); + // Attach 404 is run-gone: clear so a later Send is not C15-409'd. + expect(next.turnStatus).toBe('completed'); + expect(next.turnRunId).toBeUndefined(); } finally { vi.unstubAllGlobals(); } @@ -4778,7 +4781,67 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { (m) => m.kind === MessageKind.Error && /store unavailable/.test(m.text), ), ).toBe(true); - expect(exp.__lifecycle()).toBe(Lifecycle.Error); + expect( + exp.__messages.some((m) => isTurnEndLine(m.text)), + ).toBe(false); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(next.turnStatus).toBe('running'); + expect(next.turnRunId).toBe('wr_1'); + }); + + it('test 6c: network attach fail is subscribe-fail — EMBER, Ready, keep running, no Turn ended', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const session = runningSession(); + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_1', + startIndex: 0, + dedup: true, + attachStream: async () => ({ + ok: false as const, + error: 'Network request failed.', + turnRunId: 'wr_1', + }), + }, + }); + expect(result.ok).toBe(false); + expect( + exp.__messages.some( + (m) => m.kind === MessageKind.Error && /Network request failed/.test(m.text), + ), + ).toBe(true); + expect(exp.__messages.some((m) => isTurnEndLine(m.text))).toBe(false); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(next.turnStatus).toBe('running'); + expect(next.turnRunId).toBe('wr_1'); + }); + + it('test 6d: 401 attach is subscribe-fail — EMBER, Ready, keep running, no Turn ended', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const session = runningSession(); + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_1', + startIndex: 0, + dedup: true, + attachStream: async () => ({ + ok: false as const, + status: 401, + error: AUTH_REQUIRED_ERROR, + turnRunId: 'wr_1', + }), + }, + }); + expect(result.ok).toBe(false); + expect( + exp.__messages.some( + (m) => m.kind === MessageKind.Error && m.text.includes(AUTH_REQUIRED_ERROR), + ), + ).toBe(true); + expect(exp.__messages.some((m) => isTurnEndLine(m.text))).toBe(false); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); expect(next.turnStatus).toBe('running'); expect(next.turnRunId).toBe('wr_1'); }); diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index 42fe32c9..78fd8c7d 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -37,6 +37,7 @@ import { import { bumpStreamCursor, foldThisRunAssistant, + isAttachRunGone, shouldSkipToolResult, shouldSkipToolStart, skillAlreadyHydrated, @@ -1904,7 +1905,24 @@ export async function runHarnessTurn( agentResult.ok ? undefined : agentResult.status, opts?.signal, ); - if (fail.kind !== 'detach') { + // Adversarial #857: attach HTTP failure is two contracts, not one. + // 404 = run gone → Turn ended + Error lifecycle + clear `running`. + // 503/401/5xx/network = could not subscribe → D18-shaped persist + // (keep `running`, Ready, no Turn-ended line) + non-terminal EMBER. + const attachSubscribeFail = + attaching && + fail.kind !== 'stop' && + fail.kind !== 'detach' && + !isAttachRunGone(agentResult.ok ? undefined : agentResult.status); + if (attachSubscribeFail) { + const line = ( + agentResult.ok + ? 'Stream ended without a terminal event.' + : agentResult.error || 'Unable to attach to run stream.' + ).trim(); + bridge.pushMessage(MessageKind.Error, line); + failedSession = appendMessage(failedSession, 'error', line); + } else if (fail.kind !== 'detach') { failedSession = pushTurnEnd(bridge, failedSession, fail.kind, fail.detail); } // Phase 2 (#465): a cancel/timeout/hard-error turn still persists the last @@ -1954,7 +1972,10 @@ export async function runHarnessTurn( // `running` on `'stop'` even when the result omits the id. Do not clear // on generic error/timeout without a result id (network drop after // headers stays attach-ready). - if (fail.kind === 'detach') { + // Attach 503/401/network (adversarial #857): same keep-running as + // detach — could not subscribe ≠ the turn died. Attach 404 (run gone) + // falls through and clears so C15 does not 409 a dead id. + if (fail.kind === 'detach' || attachSubscribeFail) { const id = agentResult.turnRunId ?? (failedSession.turnStatus === 'running' @@ -1967,18 +1988,6 @@ export async function runHarnessTurn( turnStatus: 'running', }; } - } else if (attaching && fail.kind !== 'stop') { - // GET attach 404/503/auth is "could not subscribe", not "the turn - // died". Keep the live run so a later boot/F5 can retry. Never a - // server cancel. In-canvas EMBER is the pushTurnEnd above. - const id = agentResult.turnRunId ?? failedSession.turnRunId; - if (id !== undefined) { - failedSession = { - ...failedSession, - turnRunId: id, - turnStatus: 'running', - }; - } } else if ( agentResult.turnRunId !== undefined || (fail.kind === 'stop' && failedSession.turnStatus === 'running') @@ -1990,6 +1999,7 @@ export async function runHarnessTurn( }; } lastUiKind = + attachSubscribeFail || fail.kind === 'error' || fail.kind === 'timeout' || fail.kind === 'empty' || fail.kind === 'validation' ? 'error' @@ -2023,7 +2033,7 @@ export async function runHarnessTurn( // on Error (never consumes the queue head; Continue inserted at head when // non-empty) unless this was an operator Stop, which stays Ready (queue // untouched, drains only on a later success). - setFailLifecycle(bridge, fail.kind); + setFailLifecycle(bridge, attachSubscribeFail ? 'detach' : fail.kind); return { result: { ok: false, diff --git a/lib/turnAttach.test.ts b/lib/turnAttach.test.ts index 6188c74c..82024c9c 100644 --- a/lib/turnAttach.test.ts +++ b/lib/turnAttach.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { encodeToolRun, addToolStart, addToolResult, createToolRunGroup } from './toolRun'; import { appendMessage, createEmptySession, makeMessage } from './sessionStore'; import { bumpStreamCursor, decideAttachClass, + decideHotResume, + isAttachRunGone, shouldSkipToolResult, shouldSkipToolStart, skillAlreadyHydrated, @@ -98,6 +102,96 @@ describe('decideAttachClass', () => { }); }); +describe('isAttachRunGone (adversarial #857)', () => { + it('only 404 is run-gone; 503/401/5xx/network stay subscribe-fail', () => { + expect(isAttachRunGone(404)).toBe(true); + expect(isAttachRunGone(503)).toBe(false); + expect(isAttachRunGone(401)).toBe(false); + expect(isAttachRunGone(400)).toBe(false); + expect(isAttachRunGone(500)).toBe(false); + expect(isAttachRunGone(undefined)).toBe(false); + }); +}); + +describe('decideHotResume (adversarial #857 host glue)', () => { + const live = { + turnRunId: 'wr_1', + turnStatus: 'running' as const, + envelopeCursor: 12, + }; + + it('POST drop (no attachStart) hot-resumes at heap C', () => { + expect( + decideHotResume({ + ...live, + heapApplied: { runId: 'wr_1', count: 5 }, + }), + ).toEqual({ kind: 'hot', startIndex: 5 }); + }); + + it('empty-EOF GET (applied == attachStart) does not reconnect', () => { + expect( + decideHotResume({ + ...live, + heapApplied: { runId: 'wr_1', count: 5 }, + attachStart: 5, + }), + ).toEqual({ kind: 'none' }); + }); + + it('GET that applied frames past attachStart hot-resumes', () => { + expect( + decideHotResume({ + ...live, + heapApplied: { runId: 'wr_1', count: 8 }, + attachStart: 5, + }), + ).toEqual({ kind: 'hot', startIndex: 8 }); + }); + + it('cold heap / completed fold do not hot-resume', () => { + expect( + decideHotResume({ + ...live, + heapApplied: null, + attachStart: undefined, + }), + ).toEqual({ kind: 'none' }); + expect( + decideHotResume({ + turnRunId: 'wr_1', + turnStatus: 'completed', + heapApplied: { runId: 'wr_1', count: 5 }, + }), + ).toEqual({ kind: 'none' }); + }); +}); + +describe('HarnessHost attach wiring source-lock (plan #813 / adversarial #857)', () => { + const host = readFileSync(resolve(process.cwd(), 'app/harness/HarnessHost.tsx'), 'utf8'); + + it('boot / adopt / activateSession kick cold attach at startIndex=0 + dedup', () => { + expect(host).toContain('const kickColdAttach = useCallback'); + expect(host.match(/queueMicrotask\(kickColdAttach\)/g)?.length).toBeGreaterThanOrEqual(3); + expect(host).toContain('startIndex: 0, dedup: true'); + expect(host).toContain('if (inflightRef.current) return'); + }); + + it('hot resume uses decideHotResume (empty-EOF does not spin inline)', () => { + expect(host).toContain('decideHotResume('); + expect(host).not.toContain('const progressed ='); + expect(host).toContain('dedup: false'); + }); + + it('detachTurn clears inflight so switch can cold-attach', () => { + const helper = host.slice( + host.indexOf('const detachTurn = useCallback'), + host.indexOf('const runPrompt = useCallback'), + ); + expect(helper).toContain('inflightRef.current = false'); + }); +}); + describe('thisRunWindow / assistant text', () => { it('scopes after the last user row — prior-turn assistant is not in the window', () => { let s = createEmptySession(); diff --git a/lib/turnAttach.ts b/lib/turnAttach.ts index 1f945b81..54b4646c 100644 --- a/lib/turnAttach.ts +++ b/lib/turnAttach.ts @@ -51,6 +51,41 @@ export function decideAttachClass(input: { return { kind: 'hot', startIndex }; } +/** + * C16 GET 404 = run gone or ownership mismatch. Every other attach HTTP + * failure (503 store, 401 auth, 5xx, network with no status) is "could not + * subscribe" — the workflow may still be live (adversarial #857). + */ +export function isAttachRunGone(status?: number): boolean { + return status === 404; +} + +/** + * After a fold that left `running`, should this heap hot-resume? + * + * - POST (no `attachStart`): reconnect at heap C when same-heap hot. + * - GET attach: only if this heap applied frames **past** `attachStart` + * (empty-EOF GET must not spin). + * - Cold / none → do not reconnect here (F5/boot is `kickColdAttach`). + */ +export function decideHotResume(input: { + turnRunId?: string; + turnStatus?: TurnStatus; + envelopeCursor?: number; + heapApplied: HeapApplied | null; + attachStart?: number; +}): Extract | { kind: 'none' } { + const cls = decideAttachClass(input); + if (cls.kind !== 'hot') return { kind: 'none' }; + const attachStart = input.attachStart; + const applied = input.heapApplied; + const progressed = + attachStart === undefined || + (applied != null && applied.count > attachStart); + if (!progressed) return { kind: 'none' }; + return cls; +} + /** * Messages after the last `user` row — the prompt that started this `turnRunId`. * Historical assistant / tool_run / skill_attached before that line are never From 1df0acc1a44ebe4c5905d3eb3459574ab7136d24 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 15:33:53 +0000 Subject: [PATCH 03/12] fix(harness): rebuild this-run on cold attach so thinking precedes Blob suffix Adversarial #857 Major: cold attach no longer leaves Blob tool_run/assistant on the ring while reasoning_delta appends after them. Dedup hydrates through the last user, replays the stream in order, and restores the suffix on 503/404 before events so persist is not a user-only clobber. Send while a durable run is live cold-reattaches instead of POSTing (C15 409 would mix Turn ended + Error with keep-running). --- app/harness/HarnessHost.tsx | 15 ++++-- lib/harnessChat.test.ts | 92 +++++++++++++++++++++++++++++++++++++ lib/harnessChat.ts | 65 ++++++++++++++++++++++---- lib/turnAttach.test.ts | 22 +++++++++ lib/turnAttach.ts | 15 ++++++ 5 files changed, 197 insertions(+), 12 deletions(-) diff --git a/app/harness/HarnessHost.tsx b/app/harness/HarnessHost.tsx index d35c21d2..51673865 100644 --- a/app/harness/HarnessHost.tsx +++ b/app/harness/HarnessHost.tsx @@ -431,7 +431,16 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { const bridge = bridgeRef.current; if (!bridge || inflightRef.current) return; - const attaching = opts?.attach != null; + // Adversarial #857: Send while a durable run is live (503 subscribe-fail, + // empty-EOF idle) must cold-reattach — never POST (C15 409 mixes Turn + // ended + Error with keep-running). + const live = sessionRef.current; + const attach: RunPromptAttach | undefined = + opts?.attach ?? + (live.turnStatus === 'running' && live.turnRunId + ? { runId: live.turnRunId, startIndex: 0, dedup: true } + : undefined); + const attaching = attach != null; const modelId = bridge.getSelectedModel(); if (!attaching && !modelId) { setHostNote('No model selected — catalog empty, failed to load, or not granted.'); @@ -506,7 +515,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { // Adversarial #844: late patches after detach take decideDetachPersist // (never writeLocal onto a switched session; never PUT a Clear'd id). onSessionPatch: persistTurn, - ...(opts?.attach ? { attach: opts.attach } : {}), + ...(attach ? { attach } : {}), }, ); if (turnEpochRef.current !== epoch) { @@ -545,7 +554,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { turnStatus: folded.turnStatus, envelopeCursor: folded.turnStreamCursor, heapApplied: heapAppliedRef.current, - attachStart: opts?.attach?.startIndex, + attachStart: attach?.startIndex, }); if (resume.kind === 'hot') { queueMicrotask(() => { diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index fec6ea70..732517e4 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -4591,6 +4591,60 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { ); }); + it('test 2c: cold attach with Blob tool+assistant suffix replays thinking BEFORE tools (adversarial #857)', async () => { + const g = createToolRunGroup(); + addToolStart(g, 'exec'); + const payload = encodeToolRun(g)!; + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.ToolRun, payload); + bridge.pushMessage(MessageKind.Assistant, 'Hello'); + const session = runningSession([ + ['user', 'hello'], + ['tool_run', payload], + ['assistant', 'Hello'], + ]); + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'reasoning_delta', text: 'hmm' }); + await opts.onEvent?.({ type: 'tool_start', name: 'exec' }); + await opts.onEvent?.({ + type: 'tool_result', + name: 'exec', + ok: true, + summary: 'ok', + }); + await opts.onEvent?.({ type: 'text_delta', text: 'Hello' }); + await opts.onEvent?.({ type: 'text_delta', text: ' world' }); + await opts.onEvent?.({ type: 'done', text: 'Hello world' }); + return { ok: true, text: 'Hello world', turnRunId: runId }; + }, + }, + }); + expect(result.ok).toBe(true); + const kinds = exp.__messages.map((m) => m.kind); + const thinkAt = kinds.indexOf(MessageKind.Thinking); + const toolAt = kinds.indexOf(MessageKind.ToolRun); + const asstAt = kinds.lastIndexOf(MessageKind.Assistant); + expect(thinkAt).toBeGreaterThanOrEqual(0); + expect(toolAt).toBeGreaterThan(thinkAt); + expect(asstAt).toBeGreaterThan(toolAt); + expect(exp.__messages.filter((m) => m.kind === MessageKind.ToolRun)).toHaveLength(1); + expect( + exp.__messages.filter((m) => m.kind === MessageKind.Assistant).map((m) => m.text), + ).toEqual(['Hello world']); + expect(next.messages.filter((m) => m.role === 'tool_run')).toHaveLength(1); + expect(next.messages.filter((m) => m.role === 'assistant').map((m) => m.text)).toEqual([ + 'Hello world', + ]); + }); + it('test 3: two cold consumers both render thinking + text once from startIndex=0 + dedup', async () => { const events: AgentStreamEvent[] = [ { type: 'reasoning_delta', text: 'plan' }, @@ -4789,6 +4843,44 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { expect(next.turnRunId).toBe('wr_1'); }); + it('test 6e: 503 after Blob this-run suffix restores the suffix (no user-only persist)', async () => { + const g = createToolRunGroup(); + addToolStart(g, 'exec'); + const payload = encodeToolRun(g)!; + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.ToolRun, payload); + const session = runningSession([ + ['user', 'hello'], + ['tool_run', payload], + ]); + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_1', + startIndex: 0, + dedup: true, + attachStream: async () => ({ + ok: false as const, + status: 503, + error: 'Unable to attach to run stream (store unavailable).', + turnRunId: 'wr_1', + }), + }, + }); + expect(result.ok).toBe(false); + expect(next.messages.some((m) => m.role === 'tool_run')).toBe(true); + expect(exp.__messages.some((m) => m.kind === MessageKind.ToolRun)).toBe(true); + expect( + exp.__messages.some( + (m) => m.kind === MessageKind.Error && /store unavailable/.test(m.text), + ), + ).toBe(true); + expect(exp.__messages.some((m) => isTurnEndLine(m.text))).toBe(false); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(next.turnStatus).toBe('running'); + }); + it('test 6c: network attach fail is subscribe-fail — EMBER, Ready, keep running, no Turn ended', async () => { const exp = makeMockExports(); const bridge = new HarnessBridge(exp); diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index 78fd8c7d..6e5af888 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -38,6 +38,7 @@ import { bumpStreamCursor, foldThisRunAssistant, isAttachRunGone, + prefixThroughLastUser, shouldSkipToolResult, shouldSkipToolStart, skillAlreadyHydrated, @@ -1115,6 +1116,37 @@ export async function runHarnessTurn( turnStreamCursor: heapC, }; } + /** + * Cold attach (dedup): Blob this-run suffix (`tool_run` / assistant) has no + * thinking. Leaving it on the ring makes `reasoning_delta` append after the + * answer (adversarial #857 Major). Rebuild this-run from the stream: keep a + * persist backup, hydrate the ring through the last user, and let skip see + * an empty this-run window. + */ + let coldBackup: typeof next.messages | null = null; + if (attaching && dedup) { + const prefix = prefixThroughLastUser(next.messages); + if (prefix.length < next.messages.length) { + coldBackup = next.messages; + next = { ...next, messages: prefix }; + lastUiKind = restoreLastUiKind(next.messages); + try { + bridge.hydrateMessages( + prefix.map((m) => ({ kind: roleToKind(m.role), text: m.text })), + ); + } catch { + /* tests / torn-down bridge */ + } + } + } + const patchSession = (s: typeof next) => { + // Mid-attach patches must not PUT a truncated transcript over Blob. + if (coldBackup) { + opts?.onSessionPatch?.({ ...s, messages: coldBackup }); + return; + } + opts?.onSessionPatch?.(s); + }; const hydratedAssistantStart = dedup ? thisRunAssistantText(next.messages) : ''; let hydratedAssistant = hydratedAssistantStart; const hydratedTools = dedup ? thisRunToolItems(next.messages) : []; @@ -1164,10 +1196,9 @@ export async function runHarnessTurn( } } - // Hot resume (and cold hydrate) continue the last live ring row so a - // suffix `text_delta` / `reasoning_delta` grows in place instead of - // pushing a duplicate bubble. Cold attach still starts `assistantAcc` - // empty so this-run-window skip can rebuild the prefix. + // Hot resume continues the last live ring row so a suffix `text_delta` / + // `reasoning_delta` grows in place. Cold attach rebuilt through the last + // user (above); last ring is that user line, not a hydrated assistant. if (attaching) { const n = bridge.messageCount(); if (n > 0) { @@ -1276,7 +1307,7 @@ export async function runHarnessTurn( if (cd !== undefined) { next = { ...next, cwd: cd }; foldStatusSlots(bridge, next); - opts?.onSessionPatch?.(next); + patchSession(next); } } } @@ -1291,7 +1322,7 @@ export async function runHarnessTurn( next = { ...next, activeSandboxId: id }; foldStatusSlots(bridge, next); void refreshGitStatusSlot(bridge, next, opts?.signal); - opts?.onSessionPatch?.(next); + patchSession(next); } } // Phase 2 (#627 / #625): git refresh on any successful exec — no @@ -1594,7 +1625,7 @@ export async function runHarnessTurn( if (liveUsage) { next = { ...next, usage: liveUsage }; foldStatusSlots(bridge, next); - opts?.onSessionPatch?.(next); + patchSession(next); } return; } @@ -1637,7 +1668,7 @@ export async function runHarnessTurn( next.turnStatus === 'completed' ? 'completed' : 'running', turnStreamCursor: heapC, }; - opts?.onSessionPatch?.(next); + patchSession(next); }, }); if (!r.ok) { @@ -1669,7 +1700,7 @@ export async function runHarnessTurn( turnStatus: 'running', turnStreamCursor: heapC, }; - opts?.onSessionPatch?.(next); + patchSession(next); }, }) : await sendAgentFn(apiPrompt, { @@ -1914,6 +1945,22 @@ export async function runHarnessTurn( fail.kind !== 'stop' && fail.kind !== 'detach' && !isAttachRunGone(agentResult.ok ? undefined : agentResult.status); + // Cold-attach strip: if this-run never rebuilt, persist the Blob suffix + // (do not LWW-write a user-only transcript). Restore the ring only when + // nothing was painted (503/404 before events) so a thinking-only detach + // keeps the thinking row. + if (coldBackup && thisRunWindow(failedSession.messages).length === 0) { + failedSession = { ...failedSession, messages: coldBackup }; + if (!streamPainted) { + try { + bridge.hydrateMessages( + coldBackup.map((m) => ({ kind: roleToKind(m.role), text: m.text })), + ); + } catch { + /* tests / torn-down bridge */ + } + } + } if (attachSubscribeFail) { const line = ( agentResult.ok diff --git a/lib/turnAttach.test.ts b/lib/turnAttach.test.ts index 82024c9c..ad8fc7a0 100644 --- a/lib/turnAttach.test.ts +++ b/lib/turnAttach.test.ts @@ -8,6 +8,7 @@ import { decideAttachClass, decideHotResume, isAttachRunGone, + prefixThroughLastUser, shouldSkipToolResult, shouldSkipToolStart, skillAlreadyHydrated, @@ -183,6 +184,13 @@ describe('HarnessHost attach wiring source-lock (plan #813 / adversarial #857)', expect(host).toContain('dedup: false'); }); + it('Send while running cold-reattaches (never POST / C15 409)', () => { + expect(host).toContain( + 'Adversarial #857: Send while a durable run is live', + ); + expect(host).toContain('startIndex: 0, dedup: true'); + }); + it('detachTurn clears inflight so switch can cold-attach', () => { const helper = host.slice( host.indexOf('const detachTurn = useCallback'), @@ -203,6 +211,20 @@ describe('thisRunWindow / assistant text', () => { expect(w.map((m) => m.role + ':' + m.text)).toEqual(['assistant:NEW']); expect(thisRunAssistantText(s.messages)).toBe('NEW'); }); + + it('prefixThroughLastUser keeps prior turns + this prompt, drops this-run suffix', () => { + let s = createEmptySession(); + s = appendMessage(s, 'user', 'first'); + s = appendMessage(s, 'assistant', 'OLD'); + s = appendMessage(s, 'user', 'second'); + s = appendMessage(s, 'tool_run', '1 tool'); + s = appendMessage(s, 'assistant', 'NEW'); + expect(prefixThroughLastUser(s.messages).map((m) => m.role + ':' + m.text)).toEqual([ + 'user:first', + 'assistant:OLD', + 'user:second', + ]); + }); }); describe('textDeltaDedup', () => { diff --git a/lib/turnAttach.ts b/lib/turnAttach.ts index 54b4646c..4483ebcb 100644 --- a/lib/turnAttach.ts +++ b/lib/turnAttach.ts @@ -100,6 +100,21 @@ export function thisRunWindow(messages: SessionMessage[]): SessionMessage[] { return messages.slice(lastUser + 1); } +/** + * Messages through the last `user` row (inclusive) — prior turns plus this + * run's prompt. Cold attach (adversarial #857) rebuilds this-run from the + * stream: thinking is not in Blob, so a hydrated `tool_run` / assistant suffix + * cannot stay on the ring while `reasoning_delta` appends after it. + */ +export function prefixThroughLastUser(messages: SessionMessage[]): SessionMessage[] { + let lastUser = -1; + for (let i = 0; i < messages.length; i++) { + if (messages[i]?.role === 'user') lastUser = i; + } + if (lastUser < 0) return messages.slice(); + return messages.slice(0, lastUser + 1); +} + export function thisRunAssistantText(messages: SessionMessage[]): string { let acc = ''; for (const m of thisRunWindow(messages)) { From 02df4fb7656b54192876fe59aa69d016cd5d0030 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 16:48:03 +0000 Subject: [PATCH 04/12] fix(harness): classify Send-while-running by heap C, drop stray follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial #857 CONCERNS: Send on a live run always cold-attached at 0 and left the Wasm-painted follow-up user as a false prompt boundary. decideSendAttach: count>0 → hot at C; else cold + dedup. Strip the trailing follow-up before attach; always hydrate the cold prefix. --- app/harness/HarnessHost.tsx | 23 +++++-- lib/harnessChat.test.ts | 74 +++++++++++++++++++++++ lib/harnessChat.ts | 46 +++++++++++--- lib/turnAttach.test.ts | 116 +++++++++++++++++++++++++++++++++++- lib/turnAttach.ts | 74 +++++++++++++++++++++++ 5 files changed, 317 insertions(+), 16 deletions(-) diff --git a/app/harness/HarnessHost.tsx b/app/harness/HarnessHost.tsx index 51673865..d9979736 100644 --- a/app/harness/HarnessHost.tsx +++ b/app/harness/HarnessHost.tsx @@ -13,7 +13,7 @@ import { import { resetHarnessImageSession } from '../../lib/harnessImages'; import { resetHarnessMathSession } from '../../lib/harnessMath'; import { decideDetach, shouldAbortReader, abortReasonFor, decideDetachPersist, putPreservedTurn, shouldApplyMintBind, shouldSetHostTurnNote } from '../../lib/detachTurn'; -import { decideHotResume, type HeapApplied } from '../../lib/turnAttach'; +import { decideHotResume, decideSendAttach, type HeapApplied } from '../../lib/turnAttach'; import { HarnessBridge, HARNESS_PROTOCOL_VERSION, @@ -432,14 +432,25 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { if (!bridge || inflightRef.current) return; // Adversarial #857: Send while a durable run is live (503 subscribe-fail, - // empty-EOF idle) must cold-reattach — never POST (C15 409 mixes Turn - // ended + Error with keep-running). + // empty-EOF idle) must attach — never POST (C15 409 mixes Turn ended + + // Error with keep-running). Class follows this-heap applied frames, not a + // hard-coded cold-at-0 (count>0 → hot at C; else cold + dedup). const live = sessionRef.current; + const sendAttach = decideSendAttach({ + turnRunId: live.turnRunId, + turnStatus: live.turnStatus, + envelopeCursor: live.turnStreamCursor, + heapApplied: heapAppliedRef.current, + }); const attach: RunPromptAttach | undefined = opts?.attach ?? - (live.turnStatus === 'running' && live.turnRunId - ? { runId: live.turnRunId, startIndex: 0, dedup: true } - : undefined); + (sendAttach.kind === 'none' + ? undefined + : { + runId: sendAttach.runId, + startIndex: sendAttach.startIndex, + dedup: sendAttach.dedup, + }); const attaching = attach != null; const modelId = bridge.getSelectedModel(); if (!attaching && !modelId) { diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 732517e4..44147cce 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -4645,6 +4645,80 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { ]); }); + it('test 2d: Send-while-running follow-up user is stripped; thinking sits under the old prompt (adversarial #857)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.User, 'follow-up'); + const session = runningSession([['user', 'hello']]); + const { result, session: next } = await runHarnessTurn(bridge, session, 'follow-up', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'reasoning_delta', text: 'hmm' }); + await opts.onEvent?.({ type: 'text_delta', text: 'Hi' }); + await opts.onEvent?.({ type: 'done', text: 'Hi' }); + return { ok: true, text: 'Hi', turnRunId: runId }; + }, + }, + }); + expect(result.ok).toBe(true); + const users = exp.__messages.filter((m) => m.kind === MessageKind.User).map((m) => m.text); + expect(users).toEqual(['hello']); + expect(users).not.toContain('follow-up'); + const kinds = exp.__messages.map((m) => m.kind); + const userAt = kinds.indexOf(MessageKind.User); + const thinkAt = kinds.indexOf(MessageKind.Thinking); + const asstAt = kinds.indexOf(MessageKind.Assistant); + expect(userAt).toBeGreaterThanOrEqual(0); + expect(thinkAt).toBeGreaterThan(userAt); + expect(asstAt).toBeGreaterThan(thinkAt); + expect(next.messages.filter((m) => m.role === 'user').map((m) => m.text)).toEqual(['hello']); + expect(next.messages.filter((m) => m.role === 'assistant').map((m) => m.text)).toEqual(['Hi']); + }); + + it('test 2e: Send-while-running hot resume drops follow-up and grows the live assistant (adversarial #857)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.Assistant, 'Hello'); + bridge.pushMessage(MessageKind.User, 'follow-up'); + const session = runningSession( + [ + ['user', 'hello'], + ['assistant', 'Hello'], + ], + { turnStreamCursor: 7 }, + ); + const startIndexes: number[] = []; + const { result, session: next } = await runHarnessTurn(bridge, session, 'follow-up', { + attach: { + runId: 'wr_live', + startIndex: 7, + dedup: false, + attachStream: async (runId, opts: AttachInit) => { + startIndexes.push(opts.startIndex ?? 0); + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'text_delta', text: ' world' }); + await opts.onEvent?.({ type: 'done', text: 'Hello world' }); + return { ok: true, text: 'Hello world', turnRunId: runId }; + }, + }, + }); + expect(result.ok).toBe(true); + expect(startIndexes).toEqual([7]); + expect(exp.__messages.filter((m) => m.kind === MessageKind.User).map((m) => m.text)).toEqual([ + 'hello', + ]); + expect(exp.__messages.filter((m) => m.kind === MessageKind.Assistant).map((m) => m.text)).toEqual([ + 'Hello world', + ]); + expect(next.messages.filter((m) => m.role === 'user').map((m) => m.text)).toEqual(['hello']); + }); + it('test 3: two cold consumers both render thinking + text once from startIndex=0 + dedup', async () => { const events: AgentStreamEvent[] = [ { type: 'reasoning_delta', text: 'plan' }, diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index 6e5af888..4a9980c0 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -38,6 +38,7 @@ import { bumpStreamCursor, foldThisRunAssistant, isAttachRunGone, + lastUserText, prefixThroughLastUser, shouldSkipToolResult, shouldSkipToolStart, @@ -46,6 +47,7 @@ import { thisRunAssistantText, thisRunToolItems, thisRunWindow, + withoutTrailingFollowUpUser, } from './turnAttach'; import { SANDBOX_FORBIDDEN_ERROR, @@ -1116,12 +1118,40 @@ export async function runHarnessTurn( turnStreamCursor: heapC, }; } + /** + * Send-while-running (adversarial #857): Wasm already painted the follow-up + * user line before the host remapped Send to attach. Drop it so replay + * cannot sit under a prompt that was never POSTed. Empty `rawPrompt` + * (kickColdAttach / hot-resume microtask) leaves the originating user. + */ + if (attaching && (rawPrompt ?? '').trim()) { + const sessionLastUser = lastUserText(next.messages); + try { + const n = bridge.messageCount(); + const rows: { kind: MessageKind; text: string }[] = []; + for (let i = 0; i < n; i++) { + const m = bridge.messageAt(i); + if (m) rows.push(m); + } + const stripped = withoutTrailingFollowUpUser( + rows, + (m) => m.kind === MessageKind.User, + sessionLastUser, + ); + if (stripped.length !== rows.length) { + bridge.hydrateMessages(stripped); + } + } catch { + /* tests / torn-down bridge */ + } + } /** * Cold attach (dedup): Blob this-run suffix (`tool_run` / assistant) has no * thinking. Leaving it on the ring makes `reasoning_delta` append after the * answer (adversarial #857 Major). Rebuild this-run from the stream: keep a * persist backup, hydrate the ring through the last user, and let skip see - * an empty this-run window. + * an empty this-run window. Always re-hydrate the prefix (even with no + * suffix) so a Wasm follow-up cannot remain as the last user. */ let coldBackup: typeof next.messages | null = null; if (attaching && dedup) { @@ -1130,13 +1160,13 @@ export async function runHarnessTurn( coldBackup = next.messages; next = { ...next, messages: prefix }; lastUiKind = restoreLastUiKind(next.messages); - try { - bridge.hydrateMessages( - prefix.map((m) => ({ kind: roleToKind(m.role), text: m.text })), - ); - } catch { - /* tests / torn-down bridge */ - } + } + try { + bridge.hydrateMessages( + prefix.map((m) => ({ kind: roleToKind(m.role), text: m.text })), + ); + } catch { + /* tests / torn-down bridge */ } } const patchSession = (s: typeof next) => { diff --git a/lib/turnAttach.test.ts b/lib/turnAttach.test.ts index ad8fc7a0..74570546 100644 --- a/lib/turnAttach.test.ts +++ b/lib/turnAttach.test.ts @@ -7,7 +7,9 @@ import { bumpStreamCursor, decideAttachClass, decideHotResume, + decideSendAttach, isAttachRunGone, + lastUserText, prefixThroughLastUser, shouldSkipToolResult, shouldSkipToolStart, @@ -16,6 +18,7 @@ import { thisRunAssistantText, thisRunToolItems, thisRunWindow, + withoutTrailingFollowUpUser, } from './turnAttach'; import { TURN_STREAM_CURSOR_MAX } from './sessionCloudCaps'; @@ -184,11 +187,12 @@ describe('HarnessHost attach wiring source-lock (plan #813 / adversarial #857)', expect(host).toContain('dedup: false'); }); - it('Send while running cold-reattaches (never POST / C15 409)', () => { + it('Send while running uses decideSendAttach (never POST / C15 409)', () => { expect(host).toContain( 'Adversarial #857: Send while a durable run is live', ); - expect(host).toContain('startIndex: 0, dedup: true'); + expect(host).toContain('decideSendAttach('); + expect(host).toContain('heapApplied: heapAppliedRef.current'); }); it('detachTurn clears inflight so switch can cold-attach', () => { @@ -200,6 +204,114 @@ describe('HarnessHost attach wiring source-lock (plan #813 / adversarial #857)', }); }); +describe('decideSendAttach (adversarial #857 Send-while-running)', () => { + const live = { + turnRunId: 'wr_1', + turnStatus: 'running' as const, + envelopeCursor: 12, + }; + + it('heap null / count 0 is cold at 0 + dedup (Blob-shaped, not hot-at-0)', () => { + expect(decideSendAttach({ ...live, heapApplied: null })).toEqual({ + kind: 'cold', + runId: 'wr_1', + startIndex: 0, + dedup: true, + }); + expect( + decideSendAttach({ ...live, heapApplied: { runId: 'wr_1', count: 0 } }), + ).toEqual({ + kind: 'cold', + runId: 'wr_1', + startIndex: 0, + dedup: true, + }); + }); + + it('heap already applied frames is hot at C without dedup', () => { + expect( + decideSendAttach({ ...live, heapApplied: { runId: 'wr_1', count: 7 } }), + ).toEqual({ + kind: 'hot', + runId: 'wr_1', + startIndex: 7, + dedup: false, + }); + }); + + it('different run / not running → none or cold', () => { + expect( + decideSendAttach({ + ...live, + turnRunId: 'wr_new', + heapApplied: { runId: 'wr_old', count: 9 }, + }), + ).toEqual({ + kind: 'cold', + runId: 'wr_new', + startIndex: 0, + dedup: true, + }); + expect( + decideSendAttach({ + turnRunId: 'wr_1', + turnStatus: 'completed', + heapApplied: { runId: 'wr_1', count: 5 }, + }), + ).toEqual({ kind: 'none' }); + }); +}); + +describe('withoutTrailingFollowUpUser', () => { + const isUser = (r: { role: string }) => r.role === 'user'; + + it('drops a follow-up user that is not the session last user', () => { + const rows = [ + { role: 'user', text: 'hello' }, + { role: 'user', text: 'follow-up' }, + ]; + expect(withoutTrailingFollowUpUser(rows, isUser, 'hello')).toEqual([ + { role: 'user', text: 'hello' }, + ]); + }); + + it('drops a duplicate follow-up with the same text as the originating user', () => { + const rows = [ + { role: 'user', text: 'hello' }, + { role: 'thinking', text: 'hmm' }, + { role: 'user', text: 'hello' }, + ]; + expect(withoutTrailingFollowUpUser(rows, isUser, 'hello')).toEqual([ + { role: 'user', text: 'hello' }, + { role: 'thinking', text: 'hmm' }, + ]); + }); + + it('keeps the originating last-user when it is the tail', () => { + const rows = [{ role: 'user', text: 'hello' }]; + expect(withoutTrailingFollowUpUser(rows, isUser, 'hello')).toEqual(rows); + }); + + it('keeps a non-user tail (hot resume live ring)', () => { + const rows = [ + { role: 'user', text: 'hello' }, + { role: 'assistant', text: 'Hello' }, + ]; + expect(withoutTrailingFollowUpUser(rows, isUser, 'hello')).toEqual(rows); + }); + + it('lastUserText returns the last user row', () => { + expect(lastUserText([])).toBeUndefined(); + expect( + lastUserText([ + { role: 'user', text: 'a' }, + { role: 'assistant', text: 'b' }, + { role: 'user', text: 'c' }, + ]), + ).toBe('c'); + }); +}); + describe('thisRunWindow / assistant text', () => { it('scopes after the last user row — prior-turn assistant is not in the window', () => { let s = createEmptySession(); diff --git a/lib/turnAttach.ts b/lib/turnAttach.ts index 4483ebcb..f3779154 100644 --- a/lib/turnAttach.ts +++ b/lib/turnAttach.ts @@ -86,6 +86,80 @@ export function decideHotResume(input: { return cls; } +export type SendAttachSpec = + | { kind: 'none' } + | { kind: 'hot'; runId: string; startIndex: number; dedup: false } + | { kind: 'cold'; runId: string; startIndex: 0; dedup: true }; + +/** + * Classify operator Send while a durable run is still `running`. + * + * Never POST (C15 409 mixes Turn ended + Error with keep-running). Heap with + * applied frames (`count > 0`) → hot resume at `C`. Count 0 or a different / + * missing heap is Blob-shaped (503 before events, empty-EOF idle, F5) → cold + * at 0 + dedup. Hot-at-0 without dedup would replay onto a Blob suffix + * (adversarial #857 round 2). + */ +export function decideSendAttach(input: { + turnRunId?: string; + turnStatus?: TurnStatus; + envelopeCursor?: number; + heapApplied: HeapApplied | null; +}): SendAttachSpec { + const cls = decideAttachClass(input); + const runId = input.turnRunId; + if (cls.kind === 'none' || !runId) return { kind: 'none' }; + if (cls.kind === 'hot' && (input.heapApplied?.count ?? 0) > 0) { + return { + kind: 'hot', + runId, + startIndex: cls.startIndex, + dedup: false, + }; + } + return { kind: 'cold', runId, startIndex: 0, dedup: true }; +} + +/** + * Last `user` text in a transcript (originating prompt for this run, or the + * most recent user row). Undefined when the session has no user line. + */ +export function lastUserText( + messages: { role: string; text: string }[], +): string | undefined { + let text: string | undefined; + for (const m of messages) { + if (m.role === 'user') text = m.text; + } + return text; +} + +/** + * Drop a Wasm-painted follow-up user row that is not in SessionStore. + * + * Send-while-running consumes pending submit after Wasm already pushed the + * line (`pushUser:false`). Attach must not treat that row as this-run's + * prompt. Keeps the originating last-user when it is the tail. + */ +export function withoutTrailingFollowUpUser( + rows: T[], + isUser: (row: T) => boolean, + sessionLastUserText: string | undefined, +): T[] { + if (rows.length === 0) return rows; + const last = rows[rows.length - 1]!; + if (!isUser(last)) return rows; + const earlierHasSessionUser = + sessionLastUserText !== undefined && + rows.slice(0, -1).some((row) => isUser(row) && row.text === sessionLastUserText); + const lastIsNotSessionUser = + sessionLastUserText === undefined || last.text !== sessionLastUserText; + if (earlierHasSessionUser || lastIsNotSessionUser) { + return rows.slice(0, -1); + } + return rows; +} + /** * Messages after the last `user` row — the prompt that started this `turnRunId`. * Historical assistant / tool_run / skill_attached before that line are never From dda547011006468e3278df17f4b37accc0d2cd3e Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 17:24:11 +0000 Subject: [PATCH 05/12] fix(harness): persist prefix on thinking-only attach EOF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial #857 CONCERNS: coldBackup restore keyed off thisRunWindow empty, which thinking never fills. A thinking-only incomplete GET put the Blob suffix back on the session; automatic hot resume then duplicated tools. Restore the suffix only when !streamPainted (same gate as the ring). Test 2f: cold strip + reasoning_delta EOF, then hot resume at C — one tool card, thinking before tools, one assistant. Refs #813 Refs #857 --- lib/harnessChat.test.ts | 77 +++++++++++++++++++++++++++++++++++++++++ lib/harnessChat.ts | 26 +++++++------- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 44147cce..28c86b3c 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -4719,6 +4719,83 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { expect(next.messages.filter((m) => m.role === 'user').map((m) => m.text)).toEqual(['hello']); }); + it('test 2f: thinking-only EOF after cold strip then hot resume does not restore Blob suffix (adversarial #857)', async () => { + const g = createToolRunGroup(); + addToolStart(g, 'exec'); + const payload = encodeToolRun(g)!; + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.ToolRun, payload); + bridge.pushMessage(MessageKind.Assistant, 'Hello'); + const session = runningSession([ + ['user', 'hello'], + ['tool_run', payload], + ['assistant', 'Hello'], + ]); + + const first = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'reasoning_delta', text: 'hmm' }); + return { ok: true, text: '', turnRunId: runId }; + }, + }, + }); + expect(first.result.ok).toBe(false); + expect(first.session.turnStatus).toBe('running'); + expect(first.session.turnStreamCursor).toBe(1); + expect(first.session.messages.some((m) => m.role === 'tool_run')).toBe(false); + expect(first.session.messages.filter((m) => m.role === 'user').map((m) => m.text)).toEqual([ + 'hello', + ]); + expect(exp.__messages.some((m) => m.kind === MessageKind.Thinking && m.text === 'hmm')).toBe( + true, + ); + expect(exp.__messages.some((m) => m.kind === MessageKind.ToolRun)).toBe(false); + + const second = await runHarnessTurn(bridge, first.session, '', { + attach: { + runId: 'wr_live', + startIndex: first.session.turnStreamCursor ?? 1, + dedup: false, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'tool_start', name: 'exec' }); + await opts.onEvent?.({ + type: 'tool_result', + name: 'exec', + ok: true, + summary: 'ok', + }); + await opts.onEvent?.({ type: 'text_delta', text: 'Hello world' }); + await opts.onEvent?.({ type: 'done', text: 'Hello world' }); + return { ok: true, text: 'Hello world', turnRunId: runId }; + }, + }, + }); + expect(second.result.ok).toBe(true); + const kinds = exp.__messages.map((m) => m.kind); + const thinkAt = kinds.indexOf(MessageKind.Thinking); + const toolAt = kinds.indexOf(MessageKind.ToolRun); + const asstAt = kinds.lastIndexOf(MessageKind.Assistant); + expect(thinkAt).toBeGreaterThanOrEqual(0); + expect(toolAt).toBeGreaterThan(thinkAt); + expect(asstAt).toBeGreaterThan(toolAt); + expect(exp.__messages.filter((m) => m.kind === MessageKind.ToolRun)).toHaveLength(1); + expect( + exp.__messages.filter((m) => m.kind === MessageKind.Assistant).map((m) => m.text), + ).toEqual(['Hello world']); + expect(second.session.messages.filter((m) => m.role === 'tool_run')).toHaveLength(1); + expect(second.session.messages.filter((m) => m.role === 'assistant').map((m) => m.text)).toEqual([ + 'Hello world', + ]); + }); + it('test 3: two cold consumers both render thinking + text once from startIndex=0 + dedup', async () => { const events: AgentStreamEvent[] = [ { type: 'reasoning_delta', text: 'plan' }, diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index 4a9980c0..001c92a2 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -1975,20 +1975,20 @@ export async function runHarnessTurn( fail.kind !== 'stop' && fail.kind !== 'detach' && !isAttachRunGone(agentResult.ok ? undefined : agentResult.status); - // Cold-attach strip: if this-run never rebuilt, persist the Blob suffix - // (do not LWW-write a user-only transcript). Restore the ring only when - // nothing was painted (503/404 before events) so a thinking-only detach - // keeps the thinking row. - if (coldBackup && thisRunWindow(failedSession.messages).length === 0) { + // Cold-attach strip: persist the Blob suffix only when nothing was + // painted (503/404 before events) so we do not LWW a user-only + // transcript. Thinking-only incomplete GET must keep the stripped + // prefix — thinking is not in SessionStore, so thisRunWindow stays + // empty and restoring the suffix would duplicate tools on the + // automatic hot resume at C (adversarial #857). + if (coldBackup && !streamPainted) { failedSession = { ...failedSession, messages: coldBackup }; - if (!streamPainted) { - try { - bridge.hydrateMessages( - coldBackup.map((m) => ({ kind: roleToKind(m.role), text: m.text })), - ); - } catch { - /* tests / torn-down bridge */ - } + try { + bridge.hydrateMessages( + coldBackup.map((m) => ({ kind: roleToKind(m.role), text: m.text })), + ); + } catch { + /* tests / torn-down bridge */ } } if (attachSubscribeFail) { From 9e0346c76ceee1dd3b5d4b4cae5718f752cbcf50 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 18:26:13 +0000 Subject: [PATCH 06/12] fix(harness): cold attach hydrates via pushSessionToBridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial #857 CONCERNS: cold rebuild and follow-up strip called hydrateMessages, which clears Wasm image/math caches without resetting JS putOk — prior-turn media stayed blank for the page load. Submit queue was also cleared as a surgical ring edit. Cold attach and 503 restore now go through pushSessionToBridge (reset, coalesce, slice, reschedule). Hot follow-up strip uses rebuildAttachRingFromRows (same cache contract, keeps thinking). Send-while-running sets a host note that the follow-up was not a new turn. Test 2g: image session bump, prior-turn image assistant kept, consecutive tool_run coalesced. Refs #813 Refs #857 --- app/harness/HarnessHost.tsx | 6 +++- lib/harnessChat.test.ts | 51 +++++++++++++++++++++++++++++++ lib/harnessChat.ts | 61 ++++++++++++++++++++++++++++--------- lib/turnAttach.test.ts | 21 +++++++++++++ lib/turnAttach.ts | 7 +++++ 5 files changed, 130 insertions(+), 16 deletions(-) diff --git a/app/harness/HarnessHost.tsx b/app/harness/HarnessHost.tsx index d9979736..26435e31 100644 --- a/app/harness/HarnessHost.tsx +++ b/app/harness/HarnessHost.tsx @@ -13,7 +13,7 @@ import { import { resetHarnessImageSession } from '../../lib/harnessImages'; import { resetHarnessMathSession } from '../../lib/harnessMath'; import { decideDetach, shouldAbortReader, abortReasonFor, decideDetachPersist, putPreservedTurn, shouldApplyMintBind, shouldSetHostTurnNote } from '../../lib/detachTurn'; -import { decideHotResume, decideSendAttach, type HeapApplied } from '../../lib/turnAttach'; +import { decideHotResume, decideSendAttach, ATTACH_FOLLOW_UP_NOTE, type HeapApplied } from '../../lib/turnAttach'; import { HarnessBridge, HARNESS_PROTOCOL_VERSION, @@ -452,6 +452,8 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { dedup: sendAttach.dedup, }); const attaching = attach != null; + const sendWhileRunning = + opts?.attach == null && attaching && (prompt ?? '').trim().length > 0; const modelId = bridge.getSelectedModel(); if (!attaching && !modelId) { setHostNote('No model selected — catalog empty, failed to load, or not granted.'); @@ -555,6 +557,8 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { } if (!result.ok && shouldSetHostTurnNote(folded.turnStatus)) { setHostNote(result.error); + } else if (sendWhileRunning) { + setHostNote(ATTACH_FOLLOW_UP_NOTE); } // Plan #813: SSE drop while still mounted → hot resume at this-heap C. // Empty-EOF GET (applied == startIndex) must not reconnect (spin). diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 28c86b3c..952a60fe 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -53,6 +53,7 @@ import { SANDBOX_SELECTION_REQUIRED_ERROR, WORKSPACE_INSTANCE_REQUIRED_ERROR, } from './tenancy/errors'; +import { harnessImageSessionGeneration } from './harnessImages'; import { createEmptySession, formatPromptWithHistory, appendMessage, makeMessage } from './sessionStore'; import { TOOL_TRACE_SUMMARY_MAX_CHARS } from './sandbox/config'; @@ -4796,6 +4797,56 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { ]); }); + it('test 2g: cold attach uses pushSessionToBridge — image session bump, prior-turn media kept, consecutive tool_run coalesced (adversarial #857)', async () => { + const g1 = createToolRunGroup(); + addToolStart(g1, 'read_file'); + addToolResult(g1, 'read_file', true, 'ok', undefined); + const p1 = encodeToolRun(g1)!; + const g2 = createToolRunGroup(); + addToolStart(g2, 'exec'); + addToolResult(g2, 'exec', true, 'ok', undefined); + const p2 = encodeToolRun(g2)!; + const priorAsst = 'see ![shot](https://example.com/a.png)'; + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'first'); + bridge.pushMessage(MessageKind.ToolRun, p1); + bridge.pushMessage(MessageKind.ToolRun, p2); + bridge.pushMessage(MessageKind.Assistant, priorAsst); + bridge.pushMessage(MessageKind.User, 'second'); + let session = createEmptySession('s_attach_img'); + session = appendMessage(session, 'user', 'first'); + session = appendMessage(session, 'tool_run', p1); + session = appendMessage(session, 'tool_run', p2); + session = appendMessage(session, 'assistant', priorAsst); + session = appendMessage(session, 'user', 'second'); + session = { ...session, turnRunId: 'wr_live', turnStatus: 'running' }; + const gen = harnessImageSessionGeneration(); + const { result } = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'text_delta', text: 'ok' }); + await opts.onEvent?.({ type: 'done', text: 'ok' }); + return { ok: true, text: 'ok', turnRunId: runId }; + }, + }, + }); + expect(result.ok).toBe(true); + expect(harnessImageSessionGeneration()).toBeGreaterThan(gen); + expect( + exp.__messages.some((m) => m.kind === MessageKind.Assistant && m.text === priorAsst), + ).toBe(true); + expect(exp.__messages.filter((m) => m.kind === MessageKind.ToolRun)).toHaveLength(1); + expect(exp.__messages.filter((m) => m.kind === MessageKind.User).map((m) => m.text)).toEqual([ + 'first', + 'second', + ]); + }); + it('test 3: two cold consumers both render thinking + text once from startIndex=0 + dedup', async () => { const events: AgentStreamEvent[] = [ { type: 'reasoning_delta', text: 'plan' }, diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index 001c92a2..40a5f0cd 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -510,6 +510,29 @@ export function pushSessionToBridge( return windowStart; } +/** + * Adversarial #857: hot-resume follow-up strip. Thinking has no SessionStore + * role, so this cannot go through `pushSessionToBridge`. Same cache contract as + * a canonical hydrate: reset JS `putOk`, clear+repaint the kept rows, reschedule + * user/assistant media. Does not coalesce (live thinking is a separator) and + * does not touch Load-earlier (ring window is unchanged). + */ +export function rebuildAttachRingFromRows( + bridge: HarnessBridge, + rows: { kind: MessageKind; text: string }[], + session: SessionSnapshot, +): void { + resetHarnessImageSession(); + resetHarnessMathSession(); + bridge.hydrateMessages(rows); + foldStatusSlots(bridge, session); + const texts = rows + .filter((m) => m.kind === MessageKind.User || m.kind === MessageKind.Assistant) + .map((m) => m.text); + scheduleImagesFromTexts(bridge, texts); + scheduleMathFromTexts(bridge, texts); +} + /** * Host-side UTF-8-safe ellipsizer for a status-slot value (PR #543 #3). A status * slot holds at most `STATUS_SLOT_MAX_BYTES` UTF-8 bytes (Zig @@ -1119,12 +1142,16 @@ export async function runHarnessTurn( }; } /** - * Send-while-running (adversarial #857): Wasm already painted the follow-up - * user line before the host remapped Send to attach. Drop it so replay - * cannot sit under a prompt that was never POSTed. Empty `rawPrompt` - * (kickColdAttach / hot-resume microtask) leaves the originating user. + * Send-while-running hot resume (adversarial #857): Wasm already painted the + * follow-up user line before the host remapped Send to attach. Drop it so + * live grow cannot sit under a prompt that was never POSTed. Cold attach + * (dedup) rebuilds through the session last user via `pushSessionToBridge` + * below — do not `hydrateMessages` here (that would skip image/math reset + * and clear the submit queue as a surgical edit). + * Empty `rawPrompt` (kickColdAttach / hot-resume microtask) leaves the + * originating user. */ - if (attaching && (rawPrompt ?? '').trim()) { + if (attaching && !dedup && (rawPrompt ?? '').trim()) { const sessionLastUser = lastUserText(next.messages); try { const n = bridge.messageCount(); @@ -1139,7 +1166,7 @@ export async function runHarnessTurn( sessionLastUser, ); if (stripped.length !== rows.length) { - bridge.hydrateMessages(stripped); + rebuildAttachRingFromRows(bridge, stripped, next); } } catch { /* tests / torn-down bridge */ @@ -1149,9 +1176,11 @@ export async function runHarnessTurn( * Cold attach (dedup): Blob this-run suffix (`tool_run` / assistant) has no * thinking. Leaving it on the ring makes `reasoning_delta` append after the * answer (adversarial #857 Major). Rebuild this-run from the stream: keep a - * persist backup, hydrate the ring through the last user, and let skip see - * an empty this-run window. Always re-hydrate the prefix (even with no - * suffix) so a Wasm follow-up cannot remain as the last user. + * persist backup, hydrate the ring through the last user via the canonical + * `pushSessionToBridge` (reset image/math `putOk`, coalesce, slice, reschedule), + * and let skip see an empty this-run window. Always re-hydrate the prefix + * (even with no suffix) so a Wasm follow-up cannot remain as the last user + * and so boot-scheduled images are re-put after `inv_clear_messages`. */ let coldBackup: typeof next.messages | null = null; if (attaching && dedup) { @@ -1162,9 +1191,10 @@ export async function runHarnessTurn( lastUiKind = restoreLastUiKind(next.messages); } try { - bridge.hydrateMessages( - prefix.map((m) => ({ kind: roleToKind(m.role), text: m.text })), - ); + pushSessionToBridge(bridge, next, { + clear: true, + windowStart: latestRingStart(next.messages.length), + }); } catch { /* tests / torn-down bridge */ } @@ -1984,9 +2014,10 @@ export async function runHarnessTurn( if (coldBackup && !streamPainted) { failedSession = { ...failedSession, messages: coldBackup }; try { - bridge.hydrateMessages( - coldBackup.map((m) => ({ kind: roleToKind(m.role), text: m.text })), - ); + pushSessionToBridge(bridge, failedSession, { + clear: true, + windowStart: latestRingStart(failedSession.messages.length), + }); } catch { /* tests / torn-down bridge */ } diff --git a/lib/turnAttach.test.ts b/lib/turnAttach.test.ts index 74570546..87d5a386 100644 --- a/lib/turnAttach.test.ts +++ b/lib/turnAttach.test.ts @@ -10,6 +10,7 @@ import { decideSendAttach, isAttachRunGone, lastUserText, + ATTACH_FOLLOW_UP_NOTE, prefixThroughLastUser, shouldSkipToolResult, shouldSkipToolStart, @@ -193,6 +194,9 @@ describe('HarnessHost attach wiring source-lock (plan #813 / adversarial #857)', ); expect(host).toContain('decideSendAttach('); expect(host).toContain('heapApplied: heapAppliedRef.current'); + expect(host).toContain('sendWhileRunning'); + expect(host).toContain('ATTACH_FOLLOW_UP_NOTE'); + expect(host).toContain('setHostNote(ATTACH_FOLLOW_UP_NOTE)'); }); it('detachTurn clears inflight so switch can cold-attach', () => { @@ -204,6 +208,23 @@ describe('HarnessHost attach wiring source-lock (plan #813 / adversarial #857)', }); }); +describe('harnessChat attach hydrate source-lock (adversarial #857)', () => { + const src = readFileSync(resolve(process.cwd(), 'lib/harnessChat.ts'), 'utf8'); + + it('cold attach and 503 restore go through pushSessionToBridge, not raw hydrateMessages', () => { + expect(src).toContain('pushSessionToBridge(bridge, next,'); + expect(src).toContain('pushSessionToBridge(bridge, failedSession,'); + expect(src).toContain('rebuildAttachRingFromRows(bridge, stripped, next)'); + expect(src).not.toContain('bridge.hydrateMessages(\n prefix.map'); + expect(src).not.toContain('coldBackup.map((m) => ({ kind: roleToKind(m.role)'); + }); + + it('ATTACH_FOLLOW_UP_NOTE is host chrome, not a Turn-ended line', () => { + expect(ATTACH_FOLLOW_UP_NOTE).toMatch(/Follow-up not sent/); + expect(ATTACH_FOLLOW_UP_NOTE).not.toMatch(/Turn ended/); + }); +}); + describe('decideSendAttach (adversarial #857 Send-while-running)', () => { const live = { turnRunId: 'wr_1', diff --git a/lib/turnAttach.ts b/lib/turnAttach.ts index f3779154..1e2e5b87 100644 --- a/lib/turnAttach.ts +++ b/lib/turnAttach.ts @@ -18,6 +18,13 @@ import { export type HeapApplied = { runId: string; count: number }; +/** + * Host-chrome note when operator Send is remapped to attach (adversarial #857). + * Not a Turn-ended line; not EMBER. Composer text was not a new turn. + */ +export const ATTACH_FOLLOW_UP_NOTE = + 'Follow-up not sent — still attached to the live run.'; + export type AttachDecision = | { kind: 'none' } | { kind: 'hot'; startIndex: number } From d0750e3e97a469a6b3b03f8d8c3e47c6614ec9ce Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 19:06:22 +0000 Subject: [PATCH 07/12] fix(harness): preserve submit queue on Send-while-running attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial #857 CONCERNS: hydrateMessages → inv_clear_messages wiped the live FIFO. v21 inv_clear_ring replaces the ring + image/math caches without touching queue/pause/promote. Send-while-running uses preserveQueue; F5/New/switch still clear. Attach 503 retries replace the last subscribe-fail error instead of stacking. Refs #813 Refs #857 --- AGENTS.md | 2 +- docs/feature-divide.md | 2 +- lib/harnessBridge.test.ts | 66 +++++++++++- lib/harnessBridge.ts | 25 ++++- lib/harnessChat.test.ts | 152 ++++++++++++++++++++++++++++ lib/harnessChat.ts | 46 ++++++++- lib/harnessHostModelPersist.test.ts | 1 + lib/turnAttach.test.ts | 2 + native/harness/README.md | 3 +- native/harness/build.sh | 2 +- native/harness/build.zig | 1 + native/harness/src/bridge.test.zig | 25 +++++ native/harness/src/bridge.zig | 26 ++++- native/harness/src/ui.zig | 3 +- 14 files changed, 336 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f93ac36d..adaaec75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -332,7 +332,7 @@ invincible/ | Logical agent cwd + workspace-root↔abs canonicalization (`change_dir` / session / default env; **`sandbox_info`** is the bind/cwd/caps/env introspector — do not `exec env`; `canonicalizePath(R, p)` / `workspaceAbsToRel(R, abs)` / `resolvePathForTool(R, cwd, p)` / `rewriteExecRootToRel(R, text)` in `lib/agent/workPath.ts`) + **`search`** (read-grant-only code-grep via `rg`; `lib/agent/tools.ts`) | `lib/agent/workPath.ts`, `lib/agent/tools.ts`, `lib/agent/runAgent.ts`, `lib/agent/agentBody.ts`, `lib/sandbox/config.ts`, `lib/sessionStore.ts`, `lib/harnessChat.ts`, `lib/agentApi.ts`, `lib/sessionCloudCaps.ts` (shared client-safe `sanitizeSessionCwd` + Redis-safe opaque id predicate), [docs/sandbox.md](docs/sandbox.md), [docs/session-model.md](docs/session-model.md), [docs/agent-stream.md](docs/agent-stream.md). Tool paths accept **in-jail absolute paths** on all FS tools + `change_dir` + `exec` cwd: an absolute under the per-binding jail root R (`resolved.value.workspaceRoot` → `RunAgentParams.workspaceRoot` → `createAgentTools`) is canonicalized to the same workspace-relative freshness key as its relative form (BYO + Vercel parity); out-of-jail absolutes and `..`/symlink escapes fail closed. Absolute paths under `R` that **appear in `exec` stdout/stderr** are likewise rewritten to workspace-relative (`rewriteExecRootToRel` in `lib/agent/workPath.ts`, applied to `result.stdout`/`result.stderr` separately) so `exec pwd` ≡ `pwd`/annotations; when `R` is unresolvable the exec output passes through byte-for-byte (fail-open), and rewrites are capped and never throw. When R is unresolvable (BYO daemon down/pre-v2 — `workspaceRoot === null`) absolute is rejected (“root unavailable — use workspace-relative”) while relative + cwd still work. Initial request/session `cwd` stays relative-only; `.` is the workspace-root default session start (there is no `SANDBOX_DEFAULT_CWD` env knob), `..` walks up toward the workspace root and errors only past it, and an **exact ancestor** of cwd re-roots cleanly (`change_dir invincible` from `cwd=invincible/docs` → `invincible`, not the phantom `invincible/docs/invincible`) while a name-prefix sibling is never re-rooted. P1/GAP-1 (#452/#330): `cwd` + `activeSandboxId` are **session-owned** and ride the Redis record (`meta.{logicalCwd,activeSandboxId}`). `activeSandboxId` is now **server-resolved** (routing override via `lib/tenancy/resolveSandbox.ts` `requestedSandboxId`), not carry-only. A **confirmed successful `change_dir`** is persisted as the session cwd even when the turn later cancels / times out / hard-errors (`lib/harnessChat.ts` host-side `liveCwd`); the success path still prefers the authoritative `agentResult.cwd`, and only a confirmed `change_dir` (never an errored one) is stored on a failed/aborted turn. The **`exec` tool** returns a **compact summary**, not a raw dump: first `EXEC_LOG_HEAD_LINES` (10) + last `EXEC_LOG_TAIL_LINES` (10) lines per stdout/stderr with line/byte counts and `... (N lines truncated)`, each shown line byte-clipped (`EXEC_SUMMARY_LINE_MAX_BYTES`=4096) so a single fat stdio line can't inline the stream or truncate the `log:` pointer off; and when either stream is non-empty writes the full redacted output to `/.invincible/logs/exec--.log` via `client.write_file(..., mkdir: true)` (a brand-new hidden workspace dir; backends never auto-create parents; the `-` monotonic counter keeps same-ms parallel execs from overwriting), reporting two `read_file` pointers — `log: ` (cwd-relative, from nested cwd `../.invincible/logs/…`) and `log (root): ` (workspace-root-relative, read from the workspace root `cwd .`, so a depth-changing `change_dir` can never strand the full output); the write stays workspace-root, and `.invincible/` is gitignored; both pointers ride immediately after `exit=`/`TIMED_OUT` — empty output (`exec true`) writes no file, and a log-write failure fails soft with a `⚠ log write failed` note whose reason is **sanitized** (a backend/jail path never surfaces) (caps `EXEC_LOG_HEAD_LINES`/`EXEC_LOG_TAIL_LINES`=10 and `EXEC_LOG_MAX_BYTES`=8 MiB in `lib/sandbox/config.ts`) | | Cloud multi-device harness session (Redis multi-session, `/api/sessions*`, hybrid local+cloud; **phase 0 #515 envelope + Blob transcript carrier**) | `app/api/sessions/*` (+ `app/api/sessions/[id]/envelope/*`, `[id]/transcript/*`), `lib/sessionRepository.ts`, `lib/sessionCloudCaps.ts`, `lib/sessions/*` (+ `lib/sessions/blobStore.ts`, `blobStores.ts`), `lib/tenancy/harnessSessionsRedis.ts`, `lib/tenancy/harnessSessions.ts` (archive read + shared validator), `lib/di/index.ts` (root), `app/harness/HarnessHost.tsx`, `middleware.ts`, [docs/session-model.md](docs/session-model.md), [docs/bring-your-own.md](docs/bring-your-own.md), [SECURITY.md](SECURITY.md) — one-shot Postgres→Redis backfill: GHA **`sessions-redis-backfill`** (idempotent per-user marker); Postgres `harness_sessions` is a read-only archive. P1/GAP-1 (#452): session-carrier `meta.{logicalCwd,activeSandboxId}` folds into the PUT body and restores on pull/adopt; **plan #616 (source #610)** adds the reserved `meta.selectedModel` session carrier for the selected model pick (restore by id after the model catalog push; server **drops a poisoned value to unset**, never a 400). **Phase 0 (#515):** the transcript lives in **Vercel Blob** (`BLOB_READ_WRITE_TOKEN` / BYO S3-R2 seam) pointed to by `meta.transcriptPointer` on the small Redis envelope (`harness:envelope:*`); server mints short-lived scoped upload URLs for **client→Blob** uploads; legacy full-record GET stays for roll-forward while old blobs stay small. Envelope upsert/read: `PUT`/`GET /api/sessions/:id/envelope`; mint/read: `POST`/`GET /api/sessions/:id/transcript` | -| Harness stream chrome (Thinking collapse/caps, live tools) | `lib/harnessChat.ts`, `native/harness/src/ui.zig` (facade + `frame`; transcript band owner), `native/harness/src/ui/thinking.zig` (Thinking kind), `native/harness/src/transcript_split.zig` (collapsible left rail + session list in the transcript band), `native/harness/src/session_catalog.zig` (v17 catalog + pending switch), protocol **v20** in `lib/harnessBridge.ts` (Stop cancel v9; Thinking kind v8; tool-run kind 6 v10→live paint v11; skill-attach kind 7 v12; **status-slot store v13**; **turn-clock feed v14**; **v14 addendum** `inv_set_busy_tick`; **v16** model persist; **v17** session-rail catalog + pending switch; **v18** `inv_queued_count` submit-queue depth; **v19** `inv_set_queue_promote_allowed` — host arms a one-shot per-terminal scalar so a Stop/Esc/error/timeout Ready **never drains the queue**; only idle ▶ / Ctrl+Enter with an empty composer + non-empty queue promotes, plan #760; **v20** `inv_queued_insert_front` — **turn retry that never drains the queue** (plan #759): the host retries a retryable agent-turn error up to `TURN_RETRY_ATTEMPTS`=5 (NEW cap) with bounded backoff via the additive `classify` seam (`lib/sandbox/resilience.ts`), then gives up onto `Lifecycle.Error` (a failed turn is never terminal for the Wasm promote gate — `ui.zig` promotes only on successful Ready), inserting `Continue the current turn` at the queue head (`inv_queued_insert_front`) when non-empty; permanent `PERMANENT_TURN_STATUS` statuses (400/401/403/404/413/422) give up after one attempt (no loop); 408/429/5xx and timeout/empty retry up to 5 attempts — but **1 attempt** once the live stream has painted a ring row past the user line (fail-closed: replaying would re-run tools / duplicate bubbles), and **1 attempt** once a durable `/api/turns` run has started (`onTurnStarted` folded `turnRunId` + `running`; another POST would start a second workflow). Durable SSE that ends without a producer `done`/`error` after that start is **detach** (keep `turnRunId` + `running`, no Turn-ended line) — `lib/harnessChat.ts` (`sawStreamTerminal` + this-turn start flag + D18 persist fold); `lib/turnApi.ts` forwards `turnRunId` on stream-read failure. **In-canvas Pause (submit-queue hold):** a Wasm-internal `queue_paused` latch folded into `submit_queue.canPromote` (via `bridge.tryPromoteQueued`, the single promote seam) holds **every promote path** — auto-promote on successful Ready and idle empty-▶ / empty Ctrl+Enter Play — so the next turn reads from the composer; typed send + FIFO contents + enqueue/edit/remove/Clear unaffected; **auto-clears when the FIFO empties**; TEAL `· paused` toggle on the queue-band header (`n>0`); **no new export / no protocol bump / no cap change** (Wasm-ephemeral like the queue) | +| Harness stream chrome (Thinking collapse/caps, live tools) | `lib/harnessChat.ts`, `native/harness/src/ui.zig` (facade + `frame`; transcript band owner), `native/harness/src/ui/thinking.zig` (Thinking kind), `native/harness/src/transcript_split.zig` (collapsible left rail + session list in the transcript band), `native/harness/src/session_catalog.zig` (v17 catalog + pending switch), protocol **v21** in `lib/harnessBridge.ts` (Stop cancel v9; Thinking kind v8; tool-run kind 6 v10→live paint v11; skill-attach kind 7 v12; **status-slot store v13**; **turn-clock feed v14**; **v14 addendum** `inv_set_busy_tick`; **v16** model persist; **v17** session-rail catalog + pending switch; **v18** `inv_queued_count` submit-queue depth; **v19** `inv_set_queue_promote_allowed` — host arms a one-shot per-terminal scalar so a Stop/Esc/error/timeout Ready **never drains the queue**; only idle ▶ / Ctrl+Enter with an empty composer + non-empty queue promotes, plan #760; **v20** `inv_queued_insert_front` — **turn retry that never drains the queue** (plan #759): the host retries a retryable agent-turn error up to `TURN_RETRY_ATTEMPTS`=5 (NEW cap) with bounded backoff via the additive `classify` seam (`lib/sandbox/resilience.ts`), then gives up onto `Lifecycle.Error` (a failed turn is never terminal for the Wasm promote gate — `ui.zig` promotes only on successful Ready), inserting `Continue the current turn` at the queue head (`inv_queued_insert_front`) when non-empty; permanent `PERMANENT_TURN_STATUS` statuses (400/401/403/404/413/422) give up after one attempt (no loop); 408/429/5xx and timeout/empty retry up to 5 attempts — but **1 attempt** once the live stream has painted a ring row past the user line (fail-closed: replaying would re-run tools / duplicate bubbles), and **1 attempt** once a durable `/api/turns` run has started (`onTurnStarted` folded `turnRunId` + `running`; another POST would start a second workflow). Durable SSE that ends without a producer `done`/`error` after that start is **detach** (keep `turnRunId` + `running`, no Turn-ended line) — `lib/harnessChat.ts` (`sawStreamTerminal` + this-turn start flag + D18 persist fold); `lib/turnApi.ts` forwards `turnRunId` on stream-read failure. **In-canvas Pause (submit-queue hold):** a Wasm-internal `queue_paused` latch folded into `submit_queue.canPromote` (via `bridge.tryPromoteQueued`, the single promote seam) holds **every promote path** — auto-promote on successful Ready and idle empty-▶ / empty Ctrl+Enter Play — so the next turn reads from the composer; typed send + FIFO contents + enqueue/edit/remove/Clear unaffected; **auto-clears when the FIFO empties**; TEAL `· paused` toggle on the queue-band header (`n>0`); **no new export / no protocol bump / no cap change** (Wasm-ephemeral like the queue) | | Keyboard shortcuts (keymap, leader, help overlay) | `native/harness/src/keymap.zig` (single chord table + reserved-browser deny-list + leader machine; **NEW caps** `KEYMAP_MAX`=64, `LEADER_WINDOW_MS`=800), `native/harness/src/ui/keymap_dispatch.zig` (one per-frame walk of `dvui.events()`, handled-marking, leader dvui-timer arm/expiry), `native/harness/src/ui/help_overlay.zig` (modal `floatingWindow` **wide two-column table** over the transcript band — fixed chord column + remaining-width help column; wheel/trackpad scrolls the list **inside** the panel, never the transcript; a backdrop click-outside closes it; every looping widget uses a loop-unique `id_extra`, no duplicate-id red outlines), `native/harness/src/ui/metrics.zig` (help-overlay size = band fractions `HELP_OVERLAY_W_FRACTION`/`H_FRACTION` + `_MIN_*`/`_FLOOR_*` floors + `HELP_OVERLAY_CHORD_COL_W`; the fixed 460×320 `HELP_OVERLAY_W/H` cap is retired), wired in `native/harness/src/ui.zig` (dispatch before textEntry; overlay paint) + `ui/queue_band.zig` (scan removed; `queue_save`/`cancel_queue_edit` routed via dispatcher). **DOM adds no keyboard UI / `window` keydown / React cheatsheet** | | Workspace status bar (protocol v13 status-slot store; bridge overall **v14** — plan #538/#541 + Phase 2 git #540 + Phase 3 context/usage #539, **two-line bottom status bar under the composer — #554/#555/#570**) | `native/harness/src/{bridge,ui,model_picker,model_catalog}.zig` (status-slot store + two-line 64 px bar directly **below the composer**: **line 1** = identity (spinner · `h:{build-id}` · model menu) relocated from the deleted header band, **line 2** = `paintStatusSlots` right-aligned slot pack — header merged by plan #570; each line has explicit 32 px height so the model picker (`PICKER_TRIGGER_H`=32) fits and slots never clip; sandbox/cwd/git + context/usage slots — context painted generically via `STATUS_SLOT_DROP_ORDER`), `lib/harnessBridge.ts` (`StatusSlot`, `setStatusSlot`/`getStatusSlot`/`clearStatusSlot`/`clearStatusSlots`, `STATUS_SLOT_MAX_BYTES` mirror), `lib/harnessChat.ts` (`foldStatusSlots` — folds `activeSandboxId` + `cwd` + **context/usage** (`formatUsageSummary`, re-sanitized on read) after hydrate, after **every** agent turn — success **and** fail (403-clear / committed `change_dir` repaint the pack — PR #543), and **live mid-turn on tool results** (Phase 2 #627 / #625: a confirmed `change_dir` or successful `meta_sandbox_switch` repaints sandbox/cwd immediately, plus the host persists via `onSessionPatch`); context default **hidden** on missing usage, abort/cancel carries the prior honest value forward; host-ellipsized to the byte cap before the wire; `refreshGitStatusSlot` — host polls the read-only `GET /api/harness/status` probe on a ~10 s cadence **and** on-demand after a successful `exec` or `meta_sandbox_switch` mid-turn (not only the cadence), fail-soft keeps the last git value on transient error/429), `app/harness/HarnessHost.tsx` (Clear/New clears the pack; wires the git cadence + `onSessionPatch` → persist), `app/api/harness/status/route.ts` (read-only git probe: envelope-authoritative bind (`meta.activeSandboxId` wins over Redis-safe `?sandboxId=` carry), `resolveSandbox` → bounded argv-only read-only git at the bind workspace root via `lib/agent/statusProbe.ts`, per-instance rate cap `STATUS_PROBE_MIN_INTERVAL_MS`; middleware matcher + in-route `requireSessionUser` dual gate; never mutates a session/envelope — no Production write), `lib/agent/statusProbe.ts` (`STATUS_GIT_PROBE_OUT_MAX_BYTES`=512, fail-soft `{}`), `lib/sessionCloudCaps.ts` (`STATUS_SLOT_MAX_BYTES` = 96 + `STATUS_PROBE_MIN_INTERVAL_MS` = 2000 — client-safe single sources), **context/usage carrier:** `lib/agent/usageSummary.ts` (bounded provider-usage mapper `mapProviderUsage` / read-side `sanitizeUsageSummary` / host `formatUsageSummary`, `USAGE_SUMMARY_MAX_BYTES` = 96 — NEW cap), emitted **live mid-stream** from `finish` parts (aggregate only — never `finish-step` per-step counts) in `lib/agent/agentStream.ts` (SSE `usage` event), reconciled at the final `done.usage` / JSON result / chat result in `lib/agent/runAgent.ts` (+ `app/api/chat/route.ts`), parsed by `lib/agentApi.ts` / `lib/chatApi.ts`, mirrored on `SessionSnapshot.usage` (`lib/sessionStore.ts`; reserved cloud `meta.usage` JSON string, drop-to-unset on poison), docs: [docs/feature-divide.md](docs/feature-divide.md), [docs/harness-limits.md](docs/harness-limits.md), [docs/agent-stream.md](docs/agent-stream.md), [docs/session-model.md](docs/session-model.md) | | | Tool-run aggregation + expandable transcript control (#325) | `lib/agent/agentStream.ts` (backend `tool_result.preview` — bounded/redacted L2 detail), `lib/toolRun.ts` (encode/decode, host aggregation, `meaningfulDetail` preview→`detail`, `mergeToolRunPayloads`/`encodeToolRunPayload` hydrate coalesce), `lib/harnessChat.ts` (stream/JSON aggregation → kind 6 `tool_run`, **live-painted**: a tool event opens/grows ONE card immediately via `update_last` — grouping keys off the host's `lastRingRowIsToolRun` flag, the only ring writer: grow iff the last ring row is a tool-run, else a NEW card at `1`; a thinking/assistant/user/error row last is a separator; commit-once is removed; reload coalescing of consecutive `tool_run` rows via `coalesceToolRunMessages` in `pushSessionToBridge`), `lib/sessionStore.ts` role `tool_run`, `native/harness/src/rich/toolrun.zig` (decode), `native/harness/src/ui/toolrun.zig` (`paintToolRun` — **headerless**: no `tools` kind band; 📋 copy on the header row; status glyphs as the single channel from embedded faces, `✓`/`✗` DejaVu symbols + `…` Noto; L2 preview in Vera Sans Mono for command/output tools **or any multi-line detail**, body otherwise; short single-line results → static label, no blank expander), `native/harness/src/bridge.zig` + `lib/harnessBridge.ts` (protocol **v11**; additive test-only ring readback `inv_message_*_at`), protocol **v11**; expand state + stick-to-bottom reuse dvui `reorder_tree.zig` / `scrolling.zig` idioms | diff --git a/docs/feature-divide.md b/docs/feature-divide.md index 14b045a8..792d5687 100644 --- a/docs/feature-divide.md +++ b/docs/feature-divide.md @@ -145,7 +145,7 @@ re-resolved each turn. | Theme | `native/harness/src/palette.zig` ↔ `lib/palette.ts` | | Export whitelist | `native/harness/build.zig` | -Host `HARNESS_PROTOCOL_VERSION` must equal Wasm `PROTOCOL_VERSION` (currently **19** — 13 added the additive status-slot store; 14 the scalar turn-clock feed `inv_set_turn_elapsed`; 15 added the busy-tick `inv_set_busy_tick`; 16 added model-selection persistence `inv_set_selected_model` + pending-model-change; 17 added the session-rail catalog + pending switch; **18** adds `inv_queued_count` for the in-canvas submit queue; **19** adds `inv_set_queue_promote_allowed` — the host arms a one-shot per-terminal scalar so a Stop/Esc/error/timeout Ready never drains the queue, plan #760). +Host `HARNESS_PROTOCOL_VERSION` must equal Wasm `PROTOCOL_VERSION` (currently **21** — 13 added the additive status-slot store; 14 the scalar turn-clock feed `inv_set_turn_elapsed`; 15 added the busy-tick `inv_set_busy_tick`; 16 added model-selection persistence `inv_set_selected_model` + pending-model-change; 17 added the session-rail catalog + pending switch; **18** adds `inv_queued_count` for the in-canvas submit queue; **19** adds `inv_set_queue_promote_allowed` — the host arms a one-shot per-terminal scalar so a Stop/Esc/error/timeout Ready never drains the queue, plan #760; **v21** adds `inv_clear_ring` — live-session ring replace that keeps the submit queue). Mismatch → load error; rebuild both sides. Image **bytes** enter only via bridge put; never dual DOM `` product surface. ## Related diff --git a/lib/harnessBridge.test.ts b/lib/harnessBridge.test.ts index 5796d402..4b8c8e3c 100644 --- a/lib/harnessBridge.test.ts +++ b/lib/harnessBridge.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { HARNESS_PROTOCOL_VERSION, HarnessBridge, @@ -114,6 +116,9 @@ function makeMockExports(overrides?: Partial): HarnessBrid messages.length = 0; pending = null; }, + inv_clear_ring: () => { + messages.length = 0; + }, inv_echo: (ptr: number, len: number) => { echo = len === 0 ? '' : read(ptr, len); return echo.length; @@ -332,6 +337,13 @@ describe('HarnessBridge', () => { vi.restoreAllMocks(); }); + it('Zig PROTOCOL_VERSION matches HARNESS_PROTOCOL_VERSION', () => { + const zig = readFileSync(resolve(process.cwd(), 'native/harness/src/bridge.zig'), 'utf8'); + const m = zig.match(/pub const PROTOCOL_VERSION: u32 = (\d+);/); + expect(m, 'PROTOCOL_VERSION const in bridge.zig').toBeTruthy(); + expect(Number(m![1])).toBe(HARNESS_PROTOCOL_VERSION); + }); + it('fromInstance succeeds with mock exports', () => { const exports = makeMockExports(); const instance = { exports } as unknown as WebAssembly.Instance; @@ -817,7 +829,7 @@ describe('skill_attached kind (protocol v12)', () => { // Distinct from the protocol version (13) — a hardcoded kind 13 would be an // unknown kind to the Wasm painter. expect(MessageKind.SkillAttached).not.toBe(HARNESS_PROTOCOL_VERSION); - expect(HARNESS_PROTOCOL_VERSION).toBe(20); + expect(HARNESS_PROTOCOL_VERSION).toBe(21); }); it('push/readback round-trips a skill_attached row', () => { @@ -847,7 +859,7 @@ describe('setTurnElapsed (protocol v14)', () => { }); it('version bumped to 20 and the export is REQUIRED (fail-closed when missing)', () => { - expect(HARNESS_PROTOCOL_VERSION).toBe(20); + expect(HARNESS_PROTOCOL_VERSION).toBe(21); const exp = makeMockExports() as unknown as WebAssembly.Exports; expect(isHarnessBridgeExports(exp)).toBe(true); // A rebuilt Wasm that omits inv_set_turn_elapsed fails bridge-load closed, @@ -916,7 +928,7 @@ describe('status-slot pack (protocol v13)', () => { describe('queuedCount (protocol v18)', () => { it('reads inv_queued_count and fails closed when the export is missing', () => { - expect(HARNESS_PROTOCOL_VERSION).toBe(20); + expect(HARNESS_PROTOCOL_VERSION).toBe(21); const exp = makeMockExports(); const bridge = new HarnessBridge(exp); expect(bridge.queuedCount()).toBe(0); @@ -926,6 +938,52 @@ describe('queuedCount (protocol v18)', () => { }); }); +describe('clearRing / hydrateMessages preserveQueue (protocol v21, adversarial #857)', () => { + it('hydrateMessages({preserveQueue:true}) does not clear the mock FIFO', () => { + const exp = makeMockExports(); + const queue: string[] = ['keep-me']; + const origClear = exp.inv_clear_messages; + exp.inv_clear_messages = () => { + origClear(); + queue.length = 0; + }; + exp.inv_clear_ring = () => { + exp.__messages.length = 0; + }; + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.User, 'follow-up'); + bridge.hydrateMessages([{ kind: MessageKind.User, text: 'hello' }], { + preserveQueue: true, + }); + expect(exp.__messages.map((m) => m.text)).toEqual(['hello']); + expect(queue).toEqual(['keep-me']); + }); + + it('hydrateMessages() default still uses inv_clear_messages', () => { + const exp = makeMockExports(); + const queue: string[] = ['stale']; + const origClear = exp.inv_clear_messages; + exp.inv_clear_messages = () => { + origClear(); + queue.length = 0; + }; + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.hydrateMessages([{ kind: MessageKind.User, text: 'hello' }]); + expect(queue).toEqual([]); + }); + + it('inv_clear_ring export is REQUIRED (fail-closed when missing)', () => { + expect(HARNESS_PROTOCOL_VERSION).toBe(21); + const exp = makeMockExports() as unknown as WebAssembly.Exports; + expect(isHarnessBridgeExports(exp)).toBe(true); + const record = exp as unknown as Record; + delete record.inv_clear_ring; + expect(isHarnessBridgeExports(record as WebAssembly.Exports)).toBe(false); + }); +}); + describe('setQueuePromoteAllowed (protocol v19, plan #760)', () => { it('arms the one-shot scalar; default true mirrors legacy auto-promote', () => { const exp = makeMockExports(); @@ -938,7 +996,7 @@ describe('setQueuePromoteAllowed (protocol v19, plan #760)', () => { }); it('export is REQUIRED (fail-closed when missing from the wasm)', () => { - expect(HARNESS_PROTOCOL_VERSION).toBe(20); + expect(HARNESS_PROTOCOL_VERSION).toBe(21); const exp = makeMockExports() as unknown as WebAssembly.Exports; expect(isHarnessBridgeExports(exp)).toBe(true); const record = exp as unknown as Record; diff --git a/lib/harnessBridge.ts b/lib/harnessBridge.ts index 785c0837..f22330e4 100644 --- a/lib/harnessBridge.ts +++ b/lib/harnessBridge.ts @@ -38,7 +38,10 @@ import { // v20 (plan #759): submit-queue insert-at-front — `inv_queued_insert_front` // (host inserts `Continue the current turn` as the new queue head on give-up // with a non-empty queue). Additive, now REQUIRED. -export const HARNESS_PROTOCOL_VERSION = 20 as const; +// v21 (adversarial #857): `inv_clear_ring` — live-session ring + image/math +// cache replace that keeps the submit queue / pause / promote gate. F5 / New / +// switch still use `inv_clear_messages`. Additive, now REQUIRED. +export const HARNESS_PROTOCOL_VERSION = 21 as const; /** XOR constant used by `inv_ping` on the Wasm side. */ export const INV_PING_XOR = 0xa5a5 as const; @@ -145,6 +148,8 @@ export type HarnessBridgeExports = { inv_push_message: (kind: number, ptr: number, len: number) => void; inv_update_last_message: (kind: number, ptr: number, len: number) => number; inv_clear_messages: () => void; + /** Protocol v21 — ring + image/math clear; submit queue / pause / promote stay. */ + inv_clear_ring: () => void; inv_echo: (ptr: number, len: number) => number; inv_echo_len: () => number; inv_echo_copy: (outPtr: number, maxLen: number) => number; @@ -235,6 +240,7 @@ const REQUIRED_FNS: Exclude[] = [ 'inv_push_message', 'inv_update_last_message', 'inv_clear_messages', + 'inv_clear_ring', 'inv_echo', 'inv_echo_len', 'inv_echo_copy', @@ -415,14 +421,19 @@ export class HarnessBridge { /** * Host → Wasm full transcript replace (session hydrate / restore). * Batched so dvui refreshes once. + * + * `preserveQueue: true` uses `inv_clear_ring` (protocol v21) so a live + * submit FIFO / pause / promote gate survives. Default false is + * `inv_clear_messages` (F5 / New / session switch). */ hydrateMessages( messages: { kind: MessageKind; text: string }[], - opts?: { lifecycle?: Lifecycle }, + opts?: { lifecycle?: Lifecycle; preserveQueue?: boolean }, ): void { this.beginBatch(); try { - this.clearMessages(); + if (opts?.preserveQueue) this.clearRing(); + else this.clearMessages(); for (const m of messages) { this.pushMessage(m.kind, m.text); } @@ -460,6 +471,14 @@ export class HarnessBridge { this.exports.inv_clear_messages(); } + /** + * Protocol v21 — clear the transcript ring + image/math caches only. + * Submit queue, pause latch, promote gate, and pending submit stay. + */ + clearRing(): void { + this.exports.inv_clear_ring(); + } + /** * Push host-decoded non-premultiplied RGBA into Wasm image cache (protocol v4). * Returns true on success. diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 952a60fe..35c0b37a 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -132,6 +132,11 @@ function makeMockExports(): HarnessBridgeExports & { }, inv_clear_messages: () => { messages.length = 0; + queue.length = 0; + promoteAllowed = true; + }, + inv_clear_ring: () => { + messages.length = 0; }, inv_echo: () => 0, inv_echo_len: () => 0, @@ -4720,6 +4725,113 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { expect(next.messages.filter((m) => m.role === 'user').map((m) => m.text)).toEqual(['hello']); }); + it('test 2d-queue: Send-while-running cold keeps the submit FIFO (adversarial #857)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.User, 'follow-up'); + exp.__queue.push('queued A'); + exp.__queue.push('queued B'); + const session = runningSession([['user', 'hello']]); + await runHarnessTurn(bridge, session, 'follow-up', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'text_delta', text: 'Hi' }); + await opts.onEvent?.({ type: 'done', text: 'Hi' }); + return { ok: true, text: 'Hi', turnRunId: runId }; + }, + }, + }); + expect(exp.__queue).toEqual(['queued A', 'queued B']); + expect(exp.__promoteAllowed()).toBe(true); // success completeTurn re-arms + }); + + it('test 2e-queue: Send-while-running hot strip keeps the submit FIFO (adversarial #857)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.Assistant, 'Hello'); + bridge.pushMessage(MessageKind.User, 'follow-up'); + exp.__queue.push('queued A'); + const session = runningSession( + [ + ['user', 'hello'], + ['assistant', 'Hello'], + ], + { turnStreamCursor: 7 }, + ); + await runHarnessTurn(bridge, session, 'follow-up', { + attach: { + runId: 'wr_live', + startIndex: 7, + dedup: false, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'text_delta', text: ' world' }); + await opts.onEvent?.({ type: 'done', text: 'Hello world' }); + return { ok: true, text: 'Hello world', turnRunId: runId }; + }, + }, + }); + expect(exp.__queue).toEqual(['queued A']); + expect(exp.__messages.filter((m) => m.kind === MessageKind.User).map((m) => m.text)).toEqual([ + 'hello', + ]); + }); + + it('test 2h: F5 cold attach (empty prompt) still clears the submit FIFO', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + exp.__queue.push('stale from previous session'); + const session = runningSession([['user', 'hello']]); + await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'text_delta', text: 'ok' }); + await opts.onEvent?.({ type: 'done', text: 'ok' }); + return { ok: true, text: 'ok', turnRunId: runId }; + }, + }, + }); + expect(exp.__queue).toEqual([]); + }); + + it('test 2i: Send-while-running 503 keeps FIFO and arms promote false', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.User, 'follow-up'); + exp.__queue.push('queued A'); + const session = runningSession([['user', 'hello']]); + const { result, session: next } = await runHarnessTurn(bridge, session, 'follow-up', { + attach: { + runId: 'wr_1', + startIndex: 0, + dedup: true, + attachStream: async () => ({ + ok: false as const, + status: 503, + error: 'Unable to attach to run stream (store unavailable).', + turnRunId: 'wr_1', + }), + }, + }); + expect(result.ok).toBe(false); + expect(exp.__queue).toEqual(['queued A']); + expect(exp.__promoteAllowed()).toBe(false); + expect(next.turnStatus).toBe('running'); + expect(exp.__messages.some((m) => isTurnEndLine(m.text))).toBe(false); + }); + it('test 2f: thinking-only EOF after cold strip then hot resume does not restore Blob suffix (adversarial #857)', async () => { const g = createToolRunGroup(); addToolStart(g, 'exec'); @@ -5140,6 +5252,46 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { expect(next.turnRunId).toBe('wr_1'); }); + it('test 6f: second attach 503 replaces the last subscribe-fail error (no stack)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const session = runningSession(); + const first = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_1', + startIndex: 0, + dedup: true, + attachStream: async () => ({ + ok: false as const, + status: 503, + error: 'Unable to attach to run stream (store unavailable).', + turnRunId: 'wr_1', + }), + }, + }); + expect(first.session.messages.filter((m) => m.role === 'error')).toHaveLength(1); + const second = await runHarnessTurn(bridge, first.session, '', { + attach: { + runId: 'wr_1', + startIndex: 0, + dedup: true, + attachStream: async () => ({ + ok: false as const, + status: 503, + error: 'Unable to attach to run stream (retry).', + turnRunId: 'wr_1', + }), + }, + }); + const errors = second.session.messages.filter((m) => m.role === 'error'); + expect(errors).toHaveLength(1); + expect(errors[0]?.text).toMatch(/retry/); + expect( + exp.__messages.filter((m) => m.kind === MessageKind.Error && !isTurnEndLine(m.text)), + ).toHaveLength(1); + expect(second.session.turnStatus).toBe('running'); + }); + it('test 7: dedup skips hydrated this-run assistant/tool_run, never skips reasoning, prior-turn assistant is not a skip target', async () => { const g = createToolRunGroup(); addToolStart(g, 'read_file'); diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index 40a5f0cd..f2c8a0cd 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -253,6 +253,30 @@ function setFailLifecycle(bridge: HarnessBridge, kind: TurnEndKind): void { } } +/** + * Attach 503/401/network: one non-terminal EMBER row. A retry that 503s again + * replaces the last subscribe-fail error instead of stacking Blob rows + * (adversarial #857 Minor). + */ +function paintSubscribeFail( + bridge: HarnessBridge, + session: SessionSnapshot, + line: string, +): SessionSnapshot { + const last = session.messages[session.messages.length - 1]; + if (last?.role === 'error' && !isTurnEndLine(last.text)) { + const msgs = session.messages.slice(); + msgs[msgs.length - 1] = { ...last, text: line, at: Date.now() }; + const next = { ...session, messages: msgs, updatedAt: Date.now() }; + if (!bridge.updateLastMessage(MessageKind.Error, line)) { + bridge.pushMessage(MessageKind.Error, line); + } + return next; + } + bridge.pushMessage(MessageKind.Error, line); + return appendMessage(session, 'error', line); +} + export type RunHarnessChatOptions = { signal?: AbortSignal; /** Inject for tests; defaults to sendChat. */ @@ -471,6 +495,11 @@ export function pushSessionToBridge( lifecycle?: import('./harnessBridge').Lifecycle; /** Oldest session index to place in the ring; default = latest window. */ windowStart?: number; + /** + * Protocol v21 — keep the Wasm submit queue / pause / promote gate. + * Send-while-running attach only. F5 / New / switch omit this. + */ + preserveQueue?: boolean; }, ): number { const windowStart = @@ -489,6 +518,7 @@ export function pushSessionToBridge( resetHarnessMathSession(); bridge.hydrateMessages(msgs, { lifecycle: opts?.lifecycle, + preserveQueue: opts?.preserveQueue, }); foldStatusSlots(bridge, session); // Phase 2 (plan #540) — hydrate/turn-refresh: pull the git slot right after @@ -516,6 +546,8 @@ export function pushSessionToBridge( * a canonical hydrate: reset JS `putOk`, clear+repaint the kept rows, reschedule * user/assistant media. Does not coalesce (live thinking is a separator) and * does not touch Load-earlier (ring window is unchanged). + * + * Always `preserveQueue` — this is a live-session surgical edit, not F5/New. */ export function rebuildAttachRingFromRows( bridge: HarnessBridge, @@ -524,7 +556,7 @@ export function rebuildAttachRingFromRows( ): void { resetHarnessImageSession(); resetHarnessMathSession(); - bridge.hydrateMessages(rows); + bridge.hydrateMessages(rows, { preserveQueue: true }); foldStatusSlots(bridge, session); const texts = rows .filter((m) => m.kind === MessageKind.User || m.kind === MessageKind.Assistant) @@ -1130,6 +1162,10 @@ export async function runHarnessTurn( // turn end) — never a new HTTP per token. const attachOpts = opts?.attach; const dedup = attachOpts?.dedup === true; + // Send-while-running (non-empty composer / promoted queue head) is a live + // session: keep the Wasm FIFO. kickColdAttach / hot-resume microtask pass + // empty prompt — F5 / switch may clear the (empty) queue. + const preserveQueue = attaching && (rawPrompt ?? '').trim().length > 0; let heapC = attachOpts != null ? (sanitizeTurnStreamCursor(attachOpts.startIndex) ?? 0) : 0; @@ -1180,7 +1216,8 @@ export async function runHarnessTurn( * `pushSessionToBridge` (reset image/math `putOk`, coalesce, slice, reschedule), * and let skip see an empty this-run window. Always re-hydrate the prefix * (even with no suffix) so a Wasm follow-up cannot remain as the last user - * and so boot-scheduled images are re-put after `inv_clear_messages`. + * and so boot-scheduled images are re-put after the ring clear + * (`inv_clear_ring` when Send-while-running, else `inv_clear_messages`). */ let coldBackup: typeof next.messages | null = null; if (attaching && dedup) { @@ -1194,6 +1231,7 @@ export async function runHarnessTurn( pushSessionToBridge(bridge, next, { clear: true, windowStart: latestRingStart(next.messages.length), + preserveQueue, }); } catch { /* tests / torn-down bridge */ @@ -2017,6 +2055,7 @@ export async function runHarnessTurn( pushSessionToBridge(bridge, failedSession, { clear: true, windowStart: latestRingStart(failedSession.messages.length), + preserveQueue, }); } catch { /* tests / torn-down bridge */ @@ -2028,8 +2067,7 @@ export async function runHarnessTurn( ? 'Stream ended without a terminal event.' : agentResult.error || 'Unable to attach to run stream.' ).trim(); - bridge.pushMessage(MessageKind.Error, line); - failedSession = appendMessage(failedSession, 'error', line); + failedSession = paintSubscribeFail(bridge, failedSession, line); } else if (fail.kind !== 'detach') { failedSession = pushTurnEnd(bridge, failedSession, fail.kind, fail.detail); } diff --git a/lib/harnessHostModelPersist.test.ts b/lib/harnessHostModelPersist.test.ts index 14b110b2..c1a249d2 100644 --- a/lib/harnessHostModelPersist.test.ts +++ b/lib/harnessHostModelPersist.test.ts @@ -102,6 +102,7 @@ function makeMockExports(overrides?: Partial): HarnessBrid }, inv_update_last_message: () => 0, inv_clear_messages: () => { messages.length = 0; }, + inv_clear_ring: () => { messages.length = 0; }, inv_echo: () => 0, inv_echo_len: () => 0, inv_echo_copy: () => 0, diff --git a/lib/turnAttach.test.ts b/lib/turnAttach.test.ts index 87d5a386..8cfecec6 100644 --- a/lib/turnAttach.test.ts +++ b/lib/turnAttach.test.ts @@ -215,6 +215,8 @@ describe('harnessChat attach hydrate source-lock (adversarial #857)', () => { expect(src).toContain('pushSessionToBridge(bridge, next,'); expect(src).toContain('pushSessionToBridge(bridge, failedSession,'); expect(src).toContain('rebuildAttachRingFromRows(bridge, stripped, next)'); + expect(src).toContain('preserveQueue'); + expect(src).toContain('hydrateMessages(rows, { preserveQueue: true })'); expect(src).not.toContain('bridge.hydrateMessages(\n prefix.map'); expect(src).not.toContain('coldBackup.map((m) => ({ kind: roleToKind(m.role)'); }); diff --git a/native/harness/README.md b/native/harness/README.md index 8362c112..e9e0e6a3 100644 --- a/native/harness/README.md +++ b/native/harness/README.md @@ -113,6 +113,7 @@ Host is dvui’s `web.js`. Required exports (app + backend): | `inv_queued_count` | Protocol v18 — Wasm-ephemeral follow-up queue depth (host / auto-continue seam) | | `inv_set_queue_promote_allowed` | **v19** — host arms a one-shot per-terminal promote gate so a Stop/Esc/error/timeout Ready never drains a queued head (plan #760) | | `inv_queued_insert_front` | **v20** — insert a prompt as the new queue head (`Continue the current turn` on give-up, plan #759); never pops, fails closed when full/blank | +| `inv_clear_ring` | **v21** — replace transcript ring + image/math caches; **keeps** submit queue / pause / promote gate (Send-while-running attach). F5/New/switch still use `inv_clear_messages` | | `inv_set_turn_elapsed` | **v14** whole-turn busy clock — the host pushes elapsed wall-clock seconds while a turn runs; the Wasm busy row formats/appends `Waiting for model… · mm:ss` in-canvas | Whitelist: `build.zig` → `export_symbol_names` (Zig 0.16 freestanding + `entry = .disabled` strips unrooted exports). @@ -124,7 +125,7 @@ Inference stays on the host: `POST /api/chat` and `POST /api/agent` hold | | | |--|--| -| **Protocol version** | `20` (v17 added the session-rail catalog + pending switch; **v18** adds `inv_queued_count` for the in-canvas submit queue; **v19** adds `inv_set_queue_promote_allowed` — promote when Ready only, so a Stop/Esc/error/timeout Ready never drains; **v20** adds `inv_queued_insert_front` insert-at-front for turn-error Continue) | +| **Protocol version** | `21` (v17 added the session-rail catalog + pending switch; **v18** adds `inv_queued_count` for the in-canvas submit queue; **v19** adds `inv_set_queue_promote_allowed` — promote when Ready only, so a Stop/Esc/error/timeout Ready never drains; **v20** adds `inv_queued_insert_front` insert-at-front for turn-error Continue; **v21** adds `inv_clear_ring` live-session ring replace that keeps the submit queue) | | **TS** | `lib/harnessBridge.ts` | | **Zig** | `src/bridge.zig` | | **Host** | `app/harness/HarnessHost.tsx` (shell: load + bridge + APIs) | diff --git a/native/harness/build.sh b/native/harness/build.sh index 43dff997..c5b04e5a 100755 --- a/native/harness/build.sh +++ b/native/harness/build.sh @@ -52,7 +52,7 @@ if command -v node >/dev/null 2>&1; then const fs = require("fs"); const need = [ "inv_protocol_version","inv_ping","inv_set_lifecycle","inv_push_message", - "inv_clear_messages","inv_echo","inv_echo_len","inv_echo_copy", + "inv_clear_messages","inv_clear_ring","inv_echo","inv_echo_len","inv_echo_copy", "inv_has_pending_submit","inv_pending_submit_len","inv_pending_submit_copy", "inv_ack_pending_submit","dvui_init","gpa_u8","memory", ]; diff --git a/native/harness/build.zig b/native/harness/build.zig index 6a3dc901..413596d6 100644 --- a/native/harness/build.zig +++ b/native/harness/build.zig @@ -81,6 +81,7 @@ pub fn build(b: *std.Build) void { "inv_push_message", "inv_update_last_message", "inv_clear_messages", + "inv_clear_ring", "inv_echo", "inv_echo_len", "inv_echo_copy", diff --git a/native/harness/src/bridge.test.zig b/native/harness/src/bridge.test.zig index a1dbb2d3..0c23da0b 100644 --- a/native/harness/src/bridge.test.zig +++ b/native/harness/src/bridge.test.zig @@ -286,3 +286,28 @@ test "pause: reset() and inv_clear_messages clear the latch (Wasm-ephemeral, non bridge.inv_clear_messages(); // Clear / New — clears with the queue try t.expect(!bridge.isQueuePaused()); } + +test "inv_clear_ring keeps submit queue, pause latch, and promote gate (v21 / adversarial #857)" { + bridge.reset(); + _ = try bridge.enqueueFromUi("A"); + _ = try bridge.enqueueFromUi("B"); + bridge.inv_set_queue_promote_allowed(0); + bridge.setQueuePausedFromUi(true); + bridge.inv_clear_ring(); + try t.expectEqual(@as(u32, 2), bridge.queuedCount()); + try t.expectEqualStrings("A", bridge.queuedItemAt(0).?); + try t.expectEqualStrings("B", bridge.queuedItemAt(1).?); + try t.expect(!bridge.hasQueuePromoteAllowed()); + try t.expect(bridge.isQueuePaused()); +} + +test "inv_clear_messages still clears the queue (F5 / New / switch)" { + bridge.reset(); + _ = try bridge.enqueueFromUi("stale"); + bridge.inv_set_queue_promote_allowed(0); + bridge.setQueuePausedFromUi(true); + bridge.inv_clear_messages(); + try t.expectEqual(@as(u32, 0), bridge.queuedCount()); + try t.expect(bridge.hasQueuePromoteAllowed()); + try t.expect(!bridge.isQueuePaused()); +} diff --git a/native/harness/src/bridge.zig b/native/harness/src/bridge.zig index dd93502f..ecd88b30 100644 --- a/native/harness/src/bridge.zig +++ b/native/harness/src/bridge.zig @@ -41,7 +41,11 @@ const submit_queue = @import("submit_queue.zig"); /// v20: submit-queue insert-at-front — `inv_queued_insert_front` (plan #759: /// host inserts `Continue the current turn` as the new head on give-up with a /// non-empty queue). Additive, now REQUIRED. -pub const PROTOCOL_VERSION: u32 = 20; +/// v21: `inv_clear_ring` — replace the transcript ring + image/math caches +/// without touching the submit queue, pause latch, promote gate, or pending +/// submit. Live-session surgical hydrate (Send-while-running attach). F5 / New +/// / switch keep using `inv_clear_messages`. Additive, now REQUIRED. +pub const PROTOCOL_VERSION: u32 = 21; pub const Lifecycle = enum(u8) { boot = 0, @@ -631,8 +635,7 @@ export fn inv_update_last_message(kind: u8, ptr: [*]const u8, len: usize) u8 { } pub export fn inv_clear_messages() void { - msg_head = 0; - msg_count = 0; + clearRingCaches(); has_pending_cancel = false; // Hydrate / New must not leave a queued Send from the previous session. has_pending_submit = false; @@ -642,9 +645,24 @@ pub export fn inv_clear_messages() void { // Clear / New also re-arm the promote gate (fresh surface, plan #760). queue_promote_allowed = true; queue_paused = false; // plan #777 — pause latch clears with the queue + refresh(); +} + +/// Protocol v21 — replace the transcript ring + image/math caches without +/// touching the submit queue, pause latch, promote gate, pending submit, or +/// pending cancel. Send-while-running attach hydrates through this so a live +/// FIFO is not destroyed. `inv_clear_messages` remains the F5 / New / switch +/// surface (queue must go with the previous session). +pub export fn inv_clear_ring() void { + clearRingCaches(); + refresh(); +} + +fn clearRingCaches() void { + msg_head = 0; + msg_count = 0; image_cache.clear(); math_cache.clear(); - refresh(); } /// Store UTF-8 for later `inv_echo_copy`. Returns stored length (capped). diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index f78c7c98..929b1d42 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -503,7 +503,8 @@ pub fn frame() !void { if (n < prev_msg) { // Ring cleared only (count->0). No partial-truncate path: bridge's - // msg_count drops solely via inv_clear_messages — update_last never + // msg_count drops via inv_clear_messages (F5/New/switch) or + // inv_clear_ring (live-session surgical hydrate). // decreases it — so (n == 0) below is exactly the clear case. Drop the // parse cache (generation bump) and reset tool-run expand state so a // fresh window starts collapsed. From 276036a0bfdda191aed62cbe205328a79e5443a5 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 20:56:16 +0000 Subject: [PATCH 08/12] fix(harness): attach SSE error after onTurnStarted is give-up Adversarial #857 CONCERNS: attachSubscribeFail keyed only on HTTP 404, so a GET that had already opened the stream then saw producer SSE error kept running + Ready. Subscribe-fail is now !sawDurableStart (503/401/ network before onTurnStarted). After onTurnStarted, producer error reuses the POST give-up fold. Test 6g locks the sibling of the POST SSE-error row; 6h locks 5xx-without-start as subscribe-fail. Refs #813 Refs #857 --- lib/harnessChat.test.ts | 72 +++++++++++++++++++++++++++++++++++++++++ lib/harnessChat.ts | 20 ++++++++---- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 35c0b37a..be83c9ca 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -5292,6 +5292,78 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { expect(second.session.turnStatus).toBe('running'); }); + it('test 6g: onTurnStarted + SSE error is give-up, not subscribe-fail (adversarial #857)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const session = runningSession(); + const sendAgent = vi.fn(async () => { + throw new Error('must not POST /api/agent'); + }); + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + sendAgent, + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ + type: 'error', + error: 'producer failed', + status: 502, + }); + return { + ok: false as const, + error: 'producer failed', + status: 502, + turnRunId: runId, + }; + }, + }, + }); + expect(result.ok).toBe(false); + expect(sendAgent).not.toHaveBeenCalled(); + expect(next.turnRunId).toBeUndefined(); + expect(next.turnStatus).toBe('completed'); + expect( + next.messages.some( + (m) => m.role === 'error' && m.text.startsWith('Turn ended · error'), + ), + ).toBe(true); + expect(next.messages.some((m) => m.role === 'system' && /detached/.test(m.text))).toBe( + false, + ); + expect(exp.__lifecycle()).toBe(Lifecycle.Error); + expect(exp.__messages.some((m) => isTurnEndLine(m.text))).toBe(true); + }); + + it('test 6h: 502 without onTurnStarted stays subscribe-fail (discriminator)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const session = runningSession(); + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_1', + startIndex: 0, + dedup: true, + attachStream: async () => ({ + ok: false as const, + status: 502, + error: 'Bad gateway', + turnRunId: 'wr_1', + }), + }, + }); + expect(result.ok).toBe(false); + expect(next.turnStatus).toBe('running'); + expect(next.turnRunId).toBe('wr_1'); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(exp.__messages.some((m) => isTurnEndLine(m.text))).toBe(false); + expect( + exp.__messages.some((m) => m.kind === MessageKind.Error && /Bad gateway/.test(m.text)), + ).toBe(true); + }); + it('test 7: dedup skips hydrated this-run assistant/tool_run, never skips reasoning, prior-turn assistant is not a skip target', async () => { const g = createToolRunGroup(); addToolStart(g, 'read_file'); diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index f2c8a0cd..d75204ad 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -2034,12 +2034,16 @@ export async function runHarnessTurn( agentResult.ok ? undefined : agentResult.status, opts?.signal, ); - // Adversarial #857: attach HTTP failure is two contracts, not one. - // 404 = run gone → Turn ended + Error lifecycle + clear `running`. - // 503/401/5xx/network = could not subscribe → D18-shaped persist - // (keep `running`, Ready, no Turn-ended line) + non-terminal EMBER. + // Adversarial #857: subscribe-fail is "could not open a readable" + // (`!sawDurableStart`), not every non-404. 404 = run gone → Turn ended + // + Error + clear `running`. 503/401/network before onTurnStarted → + // D18-shaped persist (keep `running`, Ready, no Turn-ended) + + // non-terminal EMBER. After onTurnStarted, producer SSE error / 5xx + // reuses the POST give-up fold. EOF without terminal stays D18 via + // durableIncomplete. const attachSubscribeFail = attaching && + !sawDurableStart && fail.kind !== 'stop' && fail.kind !== 'detach' && !isAttachRunGone(agentResult.ok ? undefined : agentResult.status); @@ -2118,9 +2122,11 @@ export async function runHarnessTurn( // `running` on `'stop'` even when the result omits the id. Do not clear // on generic error/timeout without a result id (network drop after // headers stays attach-ready). - // Attach 503/401/network (adversarial #857): same keep-running as - // detach — could not subscribe ≠ the turn died. Attach 404 (run gone) - // falls through and clears so C15 does not 409 a dead id. + // Attach 503/401/network before onTurnStarted (adversarial #857): same + // keep-running as detach — could not subscribe ≠ the turn died. After + // onTurnStarted, producer SSE error reuses the POST give-up fold + // (clear `running`). Attach 404 (run gone) falls through and clears + // so C15 does not 409 a dead id. if (fail.kind === 'detach' || attachSubscribeFail) { const id = agentResult.turnRunId ?? From 4264057a45875bf38ef2eb74de4b5bfae4a8efab Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 21:56:07 +0000 Subject: [PATCH 09/12] fix(harness): persist live next after cold attach paints Adversarial #857 CONCERNS: patchSession kept substituting coldBackup after streamPainted, so usage/onTurnStarted/cwd PUTs LWW-clobbered newer this-run rows with the boot-time Blob suffix. Same gate as the fail-fold restore: persist next once painted. Send-while-running note is an in-canvas System row; host chrome mirrors in TEAL, not EMBER. Test 2j: usage after reasoning_delta + tool_start does not carry the boot assistant. Refs #813 Refs #857 --- app/harness/HarnessHost.tsx | 7 ++++- lib/harnessChat.test.ts | 55 +++++++++++++++++++++++++++++++++++++ lib/harnessChat.ts | 7 +++-- lib/turnAttach.test.ts | 11 +++++++- lib/turnAttach.ts | 5 ++-- 5 files changed, 79 insertions(+), 6 deletions(-) diff --git a/app/harness/HarnessHost.tsx b/app/harness/HarnessHost.tsx index 26435e31..4b188c37 100644 --- a/app/harness/HarnessHost.tsx +++ b/app/harness/HarnessHost.tsx @@ -559,6 +559,11 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { setHostNote(result.error); } else if (sendWhileRunning) { setHostNote(ATTACH_FOLLOW_UP_NOTE); + try { + bridge.pushMessage(MessageKind.System, ATTACH_FOLLOW_UP_NOTE); + } catch { + /* torn-down bridge */ + } } // Plan #813: SSE drop while still mounted → hot resume at this-heap C. // Empty-EOF GET (applied == startIndex) must not reconnect (spin). @@ -1250,7 +1255,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { style={{ margin: '0.5rem 1rem 0', fontSize: '0.75rem', - color: ember.muted, + color: hostNote === ATTACH_FOLLOW_UP_NOTE ? teal.muted : ember.muted, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', flexShrink: 0, }} diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index be83c9ca..6d3c3a63 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -4959,6 +4959,61 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { ]); }); + it('test 2j: cold attach usage after streamPainted persists live next, not coldBackup (adversarial #857)', async () => { + const g = createToolRunGroup(); + addToolStart(g, 'exec'); + const bootPayload = encodeToolRun(g)!; + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.ToolRun, bootPayload); + bridge.pushMessage(MessageKind.Assistant, 'Hello'); + const session = runningSession([ + ['user', 'hello'], + ['tool_run', bootPayload], + ['assistant', 'Hello'], + ]); + const patches: Array<{ + assistant: string[]; + toolRun: number; + usage?: unknown; + }> = []; + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'reasoning_delta', text: 'hmm' }); + await opts.onEvent?.({ type: 'tool_start', name: 'exec' }); + await opts.onEvent?.({ + type: 'usage', + usage: { source: 'provider', prompt: 1, completion: 2, total: 3 }, + }); + return { ok: true, text: '', turnRunId: runId }; + }, + }, + onSessionPatch: (s) => { + patches.push({ + assistant: s.messages.filter((m) => m.role === 'assistant').map((m) => m.text), + toolRun: s.messages.filter((m) => m.role === 'tool_run').length, + usage: s.usage, + }); + }, + }); + expect(result.ok).toBe(false); + expect(next.turnStatus).toBe('running'); + const started = patches[0]; + expect(started?.assistant).toEqual(['Hello']); + expect(started?.toolRun).toBe(1); + const usagePatch = patches.find((p) => p.usage != null); + expect(usagePatch).toBeTruthy(); + expect(usagePatch!.assistant).toEqual([]); + expect(usagePatch!.toolRun).toBe(1); + expect(next.messages.some((m) => m.role === 'assistant' && m.text === 'Hello')).toBe(false); + }); + it('test 3: two cold consumers both render thinking + text once from startIndex=0 + dedup', async () => { const events: AgentStreamEvent[] = [ { type: 'reasoning_delta', text: 'plan' }, diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index d75204ad..a71dbc85 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -1238,8 +1238,11 @@ export async function runHarnessTurn( } } const patchSession = (s: typeof next) => { - // Mid-attach patches must not PUT a truncated transcript over Blob. - if (coldBackup) { + // Mid-attach patches must not PUT a truncated (prefix-only) transcript + // over Blob. Once this-run has painted, `s.messages` is the live rebuild + // — persist that, not the boot-time suffix (adversarial #857). Same gate + // as the fail-fold restore. + if (coldBackup && !streamPainted) { opts?.onSessionPatch?.({ ...s, messages: coldBackup }); return; } diff --git a/lib/turnAttach.test.ts b/lib/turnAttach.test.ts index 8cfecec6..2e6c3ef7 100644 --- a/lib/turnAttach.test.ts +++ b/lib/turnAttach.test.ts @@ -197,6 +197,10 @@ describe('HarnessHost attach wiring source-lock (plan #813 / adversarial #857)', expect(host).toContain('sendWhileRunning'); expect(host).toContain('ATTACH_FOLLOW_UP_NOTE'); expect(host).toContain('setHostNote(ATTACH_FOLLOW_UP_NOTE)'); + expect(host).toContain('bridge.pushMessage(MessageKind.System, ATTACH_FOLLOW_UP_NOTE)'); + expect(host).toContain( + 'hostNote === ATTACH_FOLLOW_UP_NOTE ? teal.muted : ember.muted', + ); }); it('detachTurn clears inflight so switch can cold-attach', () => { @@ -221,7 +225,12 @@ describe('harnessChat attach hydrate source-lock (adversarial #857)', () => { expect(src).not.toContain('coldBackup.map((m) => ({ kind: roleToKind(m.role)'); }); - it('ATTACH_FOLLOW_UP_NOTE is host chrome, not a Turn-ended line', () => { + it('patchSession stops substituting coldBackup once streamPainted (adversarial #857)', () => { + expect(src).toContain('if (coldBackup && !streamPainted)'); + expect(src).not.toMatch(/if \(coldBackup\) \{\s*opts\?\.onSessionPatch\?\(\{ \.\.\.s, messages: coldBackup \}\)/); + }); + + it('ATTACH_FOLLOW_UP_NOTE is not a Turn-ended line (canvas System + TEAL host mirror)', () => { expect(ATTACH_FOLLOW_UP_NOTE).toMatch(/Follow-up not sent/); expect(ATTACH_FOLLOW_UP_NOTE).not.toMatch(/Turn ended/); }); diff --git a/lib/turnAttach.ts b/lib/turnAttach.ts index 1e2e5b87..a339f1a9 100644 --- a/lib/turnAttach.ts +++ b/lib/turnAttach.ts @@ -19,8 +19,9 @@ import { export type HeapApplied = { runId: string; count: number }; /** - * Host-chrome note when operator Send is remapped to attach (adversarial #857). - * Not a Turn-ended line; not EMBER. Composer text was not a new turn. + * Host + in-canvas note when operator Send is remapped to attach + * (adversarial #857). Not a Turn-ended line; not EMBER. Composer text was + * not a new turn. Canvas is MessageKind.System; host chrome mirrors in TEAL. */ export const ATTACH_FOLLOW_UP_NOTE = 'Follow-up not sent — still attached to the live run.'; From 57abbb1c589a339b84fc0ee4f84f9bdbdfc1861d Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 22:24:46 +0000 Subject: [PATCH 10/12] fix(harness): paint attach follow-up note only while running Adversarial #857 CONCERNS: Send-while-running painted "still attached to the live run" after attach returned, including after done (Turn ended + completed). Gate on !ok && running. shouldPaintAttachFollowUpNote is the unit lock; host source-lock drops the ungated else-if. Tests 2d/2e assert runHarnessTurn does not grow the System row on done. Refs #813 Refs #857 --- app/harness/HarnessHost.tsx | 10 +++++++-- lib/harnessChat.test.ts | 3 +++ lib/turnAttach.test.ts | 45 +++++++++++++++++++++++++++++++++++++ lib/turnAttach.ts | 19 ++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/app/harness/HarnessHost.tsx b/app/harness/HarnessHost.tsx index 4b188c37..8f728d49 100644 --- a/app/harness/HarnessHost.tsx +++ b/app/harness/HarnessHost.tsx @@ -13,7 +13,7 @@ import { import { resetHarnessImageSession } from '../../lib/harnessImages'; import { resetHarnessMathSession } from '../../lib/harnessMath'; import { decideDetach, shouldAbortReader, abortReasonFor, decideDetachPersist, putPreservedTurn, shouldApplyMintBind, shouldSetHostTurnNote } from '../../lib/detachTurn'; -import { decideHotResume, decideSendAttach, ATTACH_FOLLOW_UP_NOTE, type HeapApplied } from '../../lib/turnAttach'; +import { decideHotResume, decideSendAttach, shouldPaintAttachFollowUpNote, ATTACH_FOLLOW_UP_NOTE, type HeapApplied } from '../../lib/turnAttach'; import { HarnessBridge, HARNESS_PROTOCOL_VERSION, @@ -557,7 +557,13 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { } if (!result.ok && shouldSetHostTurnNote(folded.turnStatus)) { setHostNote(result.error); - } else if (sendWhileRunning) { + } else if ( + shouldPaintAttachFollowUpNote({ + sendWhileRunning, + resultOk: result.ok, + turnStatus: folded.turnStatus, + }) + ) { setHostNote(ATTACH_FOLLOW_UP_NOTE); try { bridge.pushMessage(MessageKind.System, ATTACH_FOLLOW_UP_NOTE); diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 6d3c3a63..512b0c07 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -26,6 +26,7 @@ import { type LiveCwdSource, } from './harnessChat'; import { HARNESS_RING_MAX } from './sessionWindow'; +import { ATTACH_FOLLOW_UP_NOTE } from './turnAttach'; import { HARNESS_PROTOCOL_VERSION, HarnessBridge, @@ -4684,6 +4685,7 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { expect(asstAt).toBeGreaterThan(thinkAt); expect(next.messages.filter((m) => m.role === 'user').map((m) => m.text)).toEqual(['hello']); expect(next.messages.filter((m) => m.role === 'assistant').map((m) => m.text)).toEqual(['Hi']); + expect(exp.__messages.map((m) => m.text)).not.toContain(ATTACH_FOLLOW_UP_NOTE); }); it('test 2e: Send-while-running hot resume drops follow-up and grows the live assistant (adversarial #857)', async () => { @@ -4723,6 +4725,7 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { 'Hello world', ]); expect(next.messages.filter((m) => m.role === 'user').map((m) => m.text)).toEqual(['hello']); + expect(exp.__messages.map((m) => m.text)).not.toContain(ATTACH_FOLLOW_UP_NOTE); }); it('test 2d-queue: Send-while-running cold keeps the submit FIFO (adversarial #857)', async () => { diff --git a/lib/turnAttach.test.ts b/lib/turnAttach.test.ts index 2e6c3ef7..18a8c786 100644 --- a/lib/turnAttach.test.ts +++ b/lib/turnAttach.test.ts @@ -11,6 +11,7 @@ import { isAttachRunGone, lastUserText, ATTACH_FOLLOW_UP_NOTE, + shouldPaintAttachFollowUpNote, prefixThroughLastUser, shouldSkipToolResult, shouldSkipToolStart, @@ -196,8 +197,10 @@ describe('HarnessHost attach wiring source-lock (plan #813 / adversarial #857)', expect(host).toContain('heapApplied: heapAppliedRef.current'); expect(host).toContain('sendWhileRunning'); expect(host).toContain('ATTACH_FOLLOW_UP_NOTE'); + expect(host).toContain('shouldPaintAttachFollowUpNote('); expect(host).toContain('setHostNote(ATTACH_FOLLOW_UP_NOTE)'); expect(host).toContain('bridge.pushMessage(MessageKind.System, ATTACH_FOLLOW_UP_NOTE)'); + expect(host).not.toContain('} else if (sendWhileRunning) {'); expect(host).toContain( 'hostNote === ATTACH_FOLLOW_UP_NOTE ? teal.muted : ember.muted', ); @@ -236,6 +239,48 @@ describe('harnessChat attach hydrate source-lock (adversarial #857)', () => { }); }); +describe('shouldPaintAttachFollowUpNote (adversarial #857 done-path lie)', () => { + it('paints only Send-while-running + still running + not ok (EOF / 503)', () => { + expect( + shouldPaintAttachFollowUpNote({ + sendWhileRunning: true, + resultOk: false, + turnStatus: 'running', + }), + ).toBe(true); + }); + + it('does not paint after done (ok + completed)', () => { + expect( + shouldPaintAttachFollowUpNote({ + sendWhileRunning: true, + resultOk: true, + turnStatus: 'completed', + }), + ).toBe(false); + }); + + it('does not paint after 404 give-up (not ok + completed)', () => { + expect( + shouldPaintAttachFollowUpNote({ + sendWhileRunning: true, + resultOk: false, + turnStatus: 'completed', + }), + ).toBe(false); + }); + + it('does not paint kickColdAttach / hot-resume (sendWhileRunning false)', () => { + expect( + shouldPaintAttachFollowUpNote({ + sendWhileRunning: false, + resultOk: false, + turnStatus: 'running', + }), + ).toBe(false); + }); +}); + describe('decideSendAttach (adversarial #857 Send-while-running)', () => { const live = { turnRunId: 'wr_1', diff --git a/lib/turnAttach.ts b/lib/turnAttach.ts index a339f1a9..d5a81b22 100644 --- a/lib/turnAttach.ts +++ b/lib/turnAttach.ts @@ -26,6 +26,25 @@ export type HeapApplied = { runId: string; count: number }; export const ATTACH_FOLLOW_UP_NOTE = 'Follow-up not sent — still attached to the live run.'; +/** + * After Send-while-running attach returns: paint the follow-up note only when + * the run is still live (D18 EOF / 503 subscribe-fail). Never after `done` + * (`result.ok` + `completed` + Turn ended) — "still attached to the live run" + * would be a lie (adversarial #857). Not at remap: a System row before GET + * would sit last on the ring (hot last-row snapshot / cold hydrate wipe). + */ +export function shouldPaintAttachFollowUpNote(input: { + sendWhileRunning: boolean; + resultOk: boolean; + turnStatus?: TurnStatus; +}): boolean { + return ( + input.sendWhileRunning && + !input.resultOk && + input.turnStatus === 'running' + ); +} + export type AttachDecision = | { kind: 'none' } | { kind: 'hot'; startIndex: number } From 5b3c5ab7891c00c686994a9f6c5f58aea1ce381f Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 22:43:40 +0000 Subject: [PATCH 11/12] fix(harness): re-POST remapped follow-up; attach Stop is D18 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial #857 CONCERNS: Send-while-running that ends not-running (done/404/give-up) re-POSTs the remapped prompt (pushUser) instead of dropping it. Attach Stop/Esc keeps running, no Turn ended · you stopped, and skips auto hot-resume that tick. feature-divide restores v20. Refs #813 Refs #857 --- app/harness/HarnessHost.tsx | 30 ++++++++++-- docs/feature-divide.md | 2 +- lib/harnessChat.test.ts | 66 +++++++++++++++++++++++++++ lib/harnessChat.ts | 12 +++-- lib/turnAttach.test.ts | 91 +++++++++++++++++++++++++++++++++++++ lib/turnAttach.ts | 27 +++++++++++ 6 files changed, 220 insertions(+), 8 deletions(-) diff --git a/app/harness/HarnessHost.tsx b/app/harness/HarnessHost.tsx index 8f728d49..6f7a7938 100644 --- a/app/harness/HarnessHost.tsx +++ b/app/harness/HarnessHost.tsx @@ -12,8 +12,8 @@ import { } from '../../lib/harnessChat'; import { resetHarnessImageSession } from '../../lib/harnessImages'; import { resetHarnessMathSession } from '../../lib/harnessMath'; -import { decideDetach, shouldAbortReader, abortReasonFor, decideDetachPersist, putPreservedTurn, shouldApplyMintBind, shouldSetHostTurnNote } from '../../lib/detachTurn'; -import { decideHotResume, decideSendAttach, shouldPaintAttachFollowUpNote, ATTACH_FOLLOW_UP_NOTE, type HeapApplied } from '../../lib/turnAttach'; +import { decideDetach, shouldAbortReader, abortReasonFor, decideDetachPersist, putPreservedTurn, shouldApplyMintBind, shouldSetHostTurnNote, isDetachAbort } from '../../lib/detachTurn'; +import { decideHotResume, decideSendAttach, shouldPaintAttachFollowUpNote, shouldRepostAttachFollowUp, shouldSkipAttachHotResume, ATTACH_FOLLOW_UP_NOTE, type HeapApplied } from '../../lib/turnAttach'; import { HarnessBridge, HARNESS_PROTOCOL_VERSION, @@ -555,7 +555,19 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { } else { heapAppliedRef.current = null; } - if (!result.ok && shouldSetHostTurnNote(folded.turnStatus)) { + // Adversarial #857: Send-while-running that finished the run (`done` / + // 404 / post-start SSE error) re-POSTs the remapped prompt — C15 409 + // no longer applies. Wasm follow-up was stripped; pushUser paints it. + if ( + shouldRepostAttachFollowUp({ + sendWhileRunning, + turnStatus: folded.turnStatus, + }) + ) { + queueMicrotask(() => { + void runPromptRef.current(prompt, { pushUser: true }); + }); + } else if (!result.ok && shouldSetHostTurnNote(folded.turnStatus)) { setHostNote(result.error); } else if ( shouldPaintAttachFollowUpNote({ @@ -574,7 +586,17 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { // Plan #813: SSE drop while still mounted → hot resume at this-heap C. // Empty-EOF GET (applied == startIndex) must not reconnect (spin). // F5 is never this path (heapApplied was nulled; activateSession is cold). - if (folded.turnStatus === 'running' && folded.turnRunId) { + // Operator Stop during attach: skip auto-resume this tick (D18 reader + // close, not G22 cancel — adversarial #857). + if ( + folded.turnStatus === 'running' && + folded.turnRunId && + !shouldSkipAttachHotResume({ + attaching, + aborted: controller.signal.aborted, + isDetachAbort: isDetachAbort(controller.signal), + }) + ) { const resume = decideHotResume({ turnRunId: folded.turnRunId, turnStatus: folded.turnStatus, diff --git a/docs/feature-divide.md b/docs/feature-divide.md index 792d5687..ec393cd2 100644 --- a/docs/feature-divide.md +++ b/docs/feature-divide.md @@ -145,7 +145,7 @@ re-resolved each turn. | Theme | `native/harness/src/palette.zig` ↔ `lib/palette.ts` | | Export whitelist | `native/harness/build.zig` | -Host `HARNESS_PROTOCOL_VERSION` must equal Wasm `PROTOCOL_VERSION` (currently **21** — 13 added the additive status-slot store; 14 the scalar turn-clock feed `inv_set_turn_elapsed`; 15 added the busy-tick `inv_set_busy_tick`; 16 added model-selection persistence `inv_set_selected_model` + pending-model-change; 17 added the session-rail catalog + pending switch; **18** adds `inv_queued_count` for the in-canvas submit queue; **19** adds `inv_set_queue_promote_allowed` — the host arms a one-shot per-terminal scalar so a Stop/Esc/error/timeout Ready never drains the queue, plan #760; **v21** adds `inv_clear_ring` — live-session ring replace that keeps the submit queue). +Host `HARNESS_PROTOCOL_VERSION` must equal Wasm `PROTOCOL_VERSION` (currently **21** — 13 added the additive status-slot store; 14 the scalar turn-clock feed `inv_set_turn_elapsed`; 15 added the busy-tick `inv_set_busy_tick`; 16 added model-selection persistence `inv_set_selected_model` + pending-model-change; 17 added the session-rail catalog + pending switch; **18** adds `inv_queued_count` for the in-canvas submit queue; **19** adds `inv_set_queue_promote_allowed` — the host arms a one-shot per-terminal scalar so a Stop/Esc/error/timeout Ready never drains the queue, plan #760; **20** adds `inv_queued_insert_front` — turn-retry Continue-on-give-up (plan #759); **v21** adds `inv_clear_ring` — live-session ring replace that keeps the submit queue). Mismatch → load error; rebuild both sides. Image **bytes** enter only via bridge put; never dual DOM `` product surface. ## Related diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index 512b0c07..cb8e623f 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -5422,6 +5422,72 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { ).toBe(true); }); + it('test 6i: attach Stop after onTurnStarted keeps running, no you-stopped (adversarial #857)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const session = runningSession(); + const sendAgent = vi.fn(async () => { + throw new Error('must not POST /api/agent'); + }); + const controller = new AbortController(); + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + sendAgent, + signal: controller.signal, + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'reasoning_delta', text: 'hmm' }); + controller.abort(); + return { ok: false as const, error: 'Request cancelled.', turnRunId: runId }; + }, + }, + }); + expect(result.ok).toBe(false); + expect(sendAgent).not.toHaveBeenCalled(); + expect(next.turnRunId).toBe('wr_live'); + expect(next.turnStatus).toBe('running'); + expect( + next.messages.some( + (m) => m.role === 'system' && m.text === describeTurnEnd('stop'), + ), + ).toBe(false); + expect(exp.__messages.some((m) => isTurnEndLine(m.text))).toBe(false); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(exp.__messages.some((m) => m.kind === MessageKind.Thinking && m.text === 'hmm')).toBe( + true, + ); + }); + + it('test 6j: attach Stop before onTurnStarted keeps running, no subscribe-fail EMBER (adversarial #857)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + const session = runningSession(); + const controller = new AbortController(); + const { result, session: next } = await runHarnessTurn(bridge, session, '', { + signal: controller.signal, + attach: { + runId: 'wr_1', + startIndex: 0, + dedup: true, + attachStream: async () => { + controller.abort(); + return { ok: false as const, error: 'Request cancelled.', turnRunId: 'wr_1' }; + }, + }, + }); + expect(result.ok).toBe(false); + expect(next.turnStatus).toBe('running'); + expect(next.turnRunId).toBe('wr_1'); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(exp.__messages.some((m) => isTurnEndLine(m.text))).toBe(false); + expect( + exp.__messages.some((m) => m.kind === MessageKind.Error), + ).toBe(false); + }); + it('test 7: dedup skips hydrated this-run assistant/tool_run, never skips reasoning, prior-turn assistant is not a skip target', async () => { const g = createToolRunGroup(); addToolStart(g, 'read_file'); diff --git a/lib/harnessChat.ts b/lib/harnessChat.ts index a71dbc85..9ab1ee92 100644 --- a/lib/harnessChat.ts +++ b/lib/harnessChat.ts @@ -2044,6 +2044,9 @@ export async function runHarnessTurn( // non-terminal EMBER. After onTurnStarted, producer SSE error / 5xx // reuses the POST give-up fold. EOF without terminal stays D18 via // durableIncomplete. + // Attach Stop/Esc: reader-only abort (D18), not G22 server cancel — + // keep `running`, no Turn ended · you stopped (adversarial #857). + const attachOperatorStop = attaching && fail.kind === 'stop'; const attachSubscribeFail = attaching && !sawDurableStart && @@ -2075,7 +2078,7 @@ export async function runHarnessTurn( : agentResult.error || 'Unable to attach to run stream.' ).trim(); failedSession = paintSubscribeFail(bridge, failedSession, line); - } else if (fail.kind !== 'detach') { + } else if (fail.kind !== 'detach' && !attachOperatorStop) { failedSession = pushTurnEnd(bridge, failedSession, fail.kind, fail.detail); } // Phase 2 (#465): a cancel/timeout/hard-error turn still persists the last @@ -2130,7 +2133,10 @@ export async function runHarnessTurn( // onTurnStarted, producer SSE error reuses the POST give-up fold // (clear `running`). Attach 404 (run gone) falls through and clears // so C15 does not 409 a dead id. - if (fail.kind === 'detach' || attachSubscribeFail) { + // Attach Stop/Esc (adversarial #857): same keep-running as detach — + // abort closes this reader only (D18); G22 owns server cancel. POST + // Stop still clears (this branch is attach-only). + if (fail.kind === 'detach' || attachSubscribeFail || attachOperatorStop) { const id = agentResult.turnRunId ?? (failedSession.turnStatus === 'running' @@ -2188,7 +2194,7 @@ export async function runHarnessTurn( // on Error (never consumes the queue head; Continue inserted at head when // non-empty) unless this was an operator Stop, which stays Ready (queue // untouched, drains only on a later success). - setFailLifecycle(bridge, attachSubscribeFail ? 'detach' : fail.kind); + setFailLifecycle(bridge, attachSubscribeFail || attachOperatorStop ? 'detach' : fail.kind); return { result: { ok: false, diff --git a/lib/turnAttach.test.ts b/lib/turnAttach.test.ts index 18a8c786..51d6bbab 100644 --- a/lib/turnAttach.test.ts +++ b/lib/turnAttach.test.ts @@ -12,6 +12,8 @@ import { lastUserText, ATTACH_FOLLOW_UP_NOTE, shouldPaintAttachFollowUpNote, + shouldRepostAttachFollowUp, + shouldSkipAttachHotResume, prefixThroughLastUser, shouldSkipToolResult, shouldSkipToolStart, @@ -198,6 +200,9 @@ describe('HarnessHost attach wiring source-lock (plan #813 / adversarial #857)', expect(host).toContain('sendWhileRunning'); expect(host).toContain('ATTACH_FOLLOW_UP_NOTE'); expect(host).toContain('shouldPaintAttachFollowUpNote('); + expect(host).toContain('shouldRepostAttachFollowUp('); + expect(host).toContain('shouldSkipAttachHotResume('); + expect(host).toContain('runPromptRef.current(prompt, { pushUser: true })'); expect(host).toContain('setHostNote(ATTACH_FOLLOW_UP_NOTE)'); expect(host).toContain('bridge.pushMessage(MessageKind.System, ATTACH_FOLLOW_UP_NOTE)'); expect(host).not.toContain('} else if (sendWhileRunning) {'); @@ -233,6 +238,12 @@ describe('harnessChat attach hydrate source-lock (adversarial #857)', () => { expect(src).not.toMatch(/if \(coldBackup\) \{\s*opts\?\.onSessionPatch\?\(\{ \.\.\.s, messages: coldBackup \}\)/); }); + it('attach Stop keep-running is D18-shaped (adversarial #857)', () => { + expect(src).toContain('const attachOperatorStop = attaching && fail.kind === \'stop\''); + expect(src).toContain('fail.kind === \'detach\' || attachSubscribeFail || attachOperatorStop'); + expect(src).toContain('fail.kind !== \'detach\' && !attachOperatorStop'); + }); + it('ATTACH_FOLLOW_UP_NOTE is not a Turn-ended line (canvas System + TEAL host mirror)', () => { expect(ATTACH_FOLLOW_UP_NOTE).toMatch(/Follow-up not sent/); expect(ATTACH_FOLLOW_UP_NOTE).not.toMatch(/Turn ended/); @@ -281,6 +292,86 @@ describe('shouldPaintAttachFollowUpNote (adversarial #857 done-path lie)', () => }); }); +describe('shouldRepostAttachFollowUp (adversarial #857 remapped prompt)', () => { + it('re-POSTs after done / 404 / give-up (not running)', () => { + expect( + shouldRepostAttachFollowUp({ + sendWhileRunning: true, + turnStatus: 'completed', + }), + ).toBe(true); + expect( + shouldRepostAttachFollowUp({ + sendWhileRunning: true, + turnStatus: undefined, + }), + ).toBe(true); + }); + + it('does not re-POST while still running (EOF / 503 note path)', () => { + expect( + shouldRepostAttachFollowUp({ + sendWhileRunning: true, + turnStatus: 'running', + }), + ).toBe(false); + }); + + it('does not re-POST kickColdAttach / hot-resume', () => { + expect( + shouldRepostAttachFollowUp({ + sendWhileRunning: false, + turnStatus: 'completed', + }), + ).toBe(false); + }); + + it('is mutually exclusive with the follow-up note', () => { + const running = { sendWhileRunning: true, resultOk: false, turnStatus: 'running' as const }; + const done = { sendWhileRunning: true, resultOk: true, turnStatus: 'completed' as const }; + expect(shouldPaintAttachFollowUpNote(running)).toBe(true); + expect(shouldRepostAttachFollowUp(running)).toBe(false); + expect(shouldPaintAttachFollowUpNote(done)).toBe(false); + expect(shouldRepostAttachFollowUp(done)).toBe(true); + }); +}); + +describe('shouldSkipAttachHotResume (adversarial #857 attach Stop)', () => { + it('skips auto-resume on operator Stop (raw abort, not detach)', () => { + expect( + shouldSkipAttachHotResume({ + attaching: true, + aborted: true, + isDetachAbort: false, + }), + ).toBe(true); + }); + + it('does not skip EOF / unmount detach / non-attach', () => { + expect( + shouldSkipAttachHotResume({ + attaching: true, + aborted: false, + isDetachAbort: false, + }), + ).toBe(false); + expect( + shouldSkipAttachHotResume({ + attaching: true, + aborted: true, + isDetachAbort: true, + }), + ).toBe(false); + expect( + shouldSkipAttachHotResume({ + attaching: false, + aborted: true, + isDetachAbort: false, + }), + ).toBe(false); + }); +}); + describe('decideSendAttach (adversarial #857 Send-while-running)', () => { const live = { turnRunId: 'wr_1', diff --git a/lib/turnAttach.ts b/lib/turnAttach.ts index d5a81b22..38cce24f 100644 --- a/lib/turnAttach.ts +++ b/lib/turnAttach.ts @@ -45,6 +45,33 @@ export function shouldPaintAttachFollowUpNote(input: { ); } +/** + * After Send-while-running attach returns: POST the remapped prompt when the + * run is no longer live (`done` / 404 / post-start SSE error). C15 409 no + * longer applies. Never while `running` (EOF / 503 — note path). Never for + * kickColdAttach / hot-resume (empty prompt, `sendWhileRunning` false). + */ +export function shouldRepostAttachFollowUp(input: { + sendWhileRunning: boolean; + turnStatus?: TurnStatus; +}): boolean { + return input.sendWhileRunning && input.turnStatus !== 'running'; +} + +/** + * Operator Stop/Esc during attach: do not auto hot-resume this tick. + * Abort is this reader only (D18); G22 owns server cancel. Raw abort (not + * `DETACH_ABORT_REASON`) is canvas Stop. Unmount/switch already returns on + * epoch before resume. + */ +export function shouldSkipAttachHotResume(input: { + attaching: boolean; + aborted: boolean; + isDetachAbort: boolean; +}): boolean { + return input.attaching && input.aborted && !input.isDetachAbort; +} + export type AttachDecision = | { kind: 'none' } | { kind: 'hot'; startIndex: number } From 126cdba1c0915e6b0ba0f26c88fa5906cd5098b0 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 26 Aug 2026 23:01:25 +0000 Subject: [PATCH 12/12] fix(harness): attach Stop follow-up note is detach, not still-attached Adversarial #857 CONCERNS: Send-while-running Stop painted "still attached to the live run" after shouldSkipAttachHotResume closed the GET. Gate the still-attached note on !operatorStop; paint a distinct TEAL System line. Keep running, no Turn ended. Tests: shouldPaint* operatorStop rows; host source-lock; 6k Send-while-running + Stop strips follow-up, no still-attached note. Refs #813 Refs #857 --- app/harness/HarnessHost.tsx | 29 +++++++++--- lib/harnessChat.test.ts | 48 +++++++++++++++++++- lib/turnAttach.test.ts | 88 ++++++++++++++++++++++++++++++++++++- lib/turnAttach.ts | 36 ++++++++++++++- 4 files changed, 191 insertions(+), 10 deletions(-) diff --git a/app/harness/HarnessHost.tsx b/app/harness/HarnessHost.tsx index 6f7a7938..c4a3eed1 100644 --- a/app/harness/HarnessHost.tsx +++ b/app/harness/HarnessHost.tsx @@ -13,7 +13,7 @@ import { import { resetHarnessImageSession } from '../../lib/harnessImages'; import { resetHarnessMathSession } from '../../lib/harnessMath'; import { decideDetach, shouldAbortReader, abortReasonFor, decideDetachPersist, putPreservedTurn, shouldApplyMintBind, shouldSetHostTurnNote, isDetachAbort } from '../../lib/detachTurn'; -import { decideHotResume, decideSendAttach, shouldPaintAttachFollowUpNote, shouldRepostAttachFollowUp, shouldSkipAttachHotResume, ATTACH_FOLLOW_UP_NOTE, type HeapApplied } from '../../lib/turnAttach'; +import { decideHotResume, decideSendAttach, shouldPaintAttachFollowUpNote, shouldPaintAttachFollowUpDetachNote, shouldRepostAttachFollowUp, shouldSkipAttachHotResume, ATTACH_FOLLOW_UP_NOTE, ATTACH_FOLLOW_UP_DETACH_NOTE, isAttachFollowUpHostNote, type HeapApplied } from '../../lib/turnAttach'; import { HarnessBridge, HARNESS_PROTOCOL_VERSION, @@ -558,6 +558,11 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { // Adversarial #857: Send-while-running that finished the run (`done` / // 404 / post-start SSE error) re-POSTs the remapped prompt — C15 409 // no longer applies. Wasm follow-up was stripped; pushUser paints it. + const operatorStop = shouldSkipAttachHotResume({ + attaching, + aborted: controller.signal.aborted, + isDetachAbort: isDetachAbort(controller.signal), + }); if ( shouldRepostAttachFollowUp({ sendWhileRunning, @@ -574,6 +579,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { sendWhileRunning, resultOk: result.ok, turnStatus: folded.turnStatus, + operatorStop, }) ) { setHostNote(ATTACH_FOLLOW_UP_NOTE); @@ -582,6 +588,19 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { } catch { /* torn-down bridge */ } + } else if ( + shouldPaintAttachFollowUpDetachNote({ + sendWhileRunning, + operatorStop, + turnStatus: folded.turnStatus, + }) + ) { + setHostNote(ATTACH_FOLLOW_UP_DETACH_NOTE); + try { + bridge.pushMessage(MessageKind.System, ATTACH_FOLLOW_UP_DETACH_NOTE); + } catch { + /* torn-down bridge */ + } } // Plan #813: SSE drop while still mounted → hot resume at this-heap C. // Empty-EOF GET (applied == startIndex) must not reconnect (spin). @@ -591,11 +610,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { if ( folded.turnStatus === 'running' && folded.turnRunId && - !shouldSkipAttachHotResume({ - attaching, - aborted: controller.signal.aborted, - isDetachAbort: isDetachAbort(controller.signal), - }) + !operatorStop ) { const resume = decideHotResume({ turnRunId: folded.turnRunId, @@ -1283,7 +1298,7 @@ export default function HarnessHost({ authNav }: { authNav?: ReactNode } = {}) { style={{ margin: '0.5rem 1rem 0', fontSize: '0.75rem', - color: hostNote === ATTACH_FOLLOW_UP_NOTE ? teal.muted : ember.muted, + color: isAttachFollowUpHostNote(hostNote) ? teal.muted : ember.muted, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', flexShrink: 0, }} diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts index cb8e623f..38ffe73f 100644 --- a/lib/harnessChat.test.ts +++ b/lib/harnessChat.test.ts @@ -26,7 +26,7 @@ import { type LiveCwdSource, } from './harnessChat'; import { HARNESS_RING_MAX } from './sessionWindow'; -import { ATTACH_FOLLOW_UP_NOTE } from './turnAttach'; +import { ATTACH_FOLLOW_UP_NOTE, ATTACH_FOLLOW_UP_DETACH_NOTE } from './turnAttach'; import { HARNESS_PROTOCOL_VERSION, HarnessBridge, @@ -5488,6 +5488,52 @@ describe('runHarnessTurn attach handshake (plan #813 / E19)', () => { ).toBe(false); }); + it('test 6k: Send-while-running attach Stop keeps running, strips follow-up, no still-attached note (adversarial #857)', async () => { + const exp = makeMockExports(); + const bridge = new HarnessBridge(exp); + bridge.pushMessage(MessageKind.User, 'hello'); + bridge.pushMessage(MessageKind.User, 'follow-up'); + const session = runningSession([['user', 'hello']]); + const sendAgent = vi.fn(async () => { + throw new Error('must not POST /api/agent'); + }); + const controller = new AbortController(); + const { result, session: next } = await runHarnessTurn(bridge, session, 'follow-up', { + sendAgent, + signal: controller.signal, + attach: { + runId: 'wr_live', + startIndex: 0, + dedup: true, + attachStream: async (runId, opts: AttachInit) => { + await opts.onTurnStarted?.({ turnRunId: runId }); + await opts.onEvent?.({ type: 'reasoning_delta', text: 'hmm' }); + controller.abort(); + return { ok: false as const, error: 'Request cancelled.', turnRunId: runId }; + }, + }, + }); + expect(result.ok).toBe(false); + expect(sendAgent).not.toHaveBeenCalled(); + expect(next.turnRunId).toBe('wr_live'); + expect(next.turnStatus).toBe('running'); + expect( + next.messages.some( + (m) => m.role === 'system' && m.text === describeTurnEnd('stop'), + ), + ).toBe(false); + expect(exp.__messages.some((m) => isTurnEndLine(m.text))).toBe(false); + expect(exp.__lifecycle()).toBe(Lifecycle.Ready); + expect(exp.__messages.filter((m) => m.kind === MessageKind.User).map((m) => m.text)).toEqual([ + 'hello', + ]); + expect(exp.__messages.map((m) => m.text)).not.toContain(ATTACH_FOLLOW_UP_NOTE); + expect(exp.__messages.map((m) => m.text)).not.toContain(ATTACH_FOLLOW_UP_DETACH_NOTE); + expect(exp.__messages.some((m) => m.kind === MessageKind.Thinking && m.text === 'hmm')).toBe( + true, + ); + }); + it('test 7: dedup skips hydrated this-run assistant/tool_run, never skips reasoning, prior-turn assistant is not a skip target', async () => { const g = createToolRunGroup(); addToolStart(g, 'read_file'); diff --git a/lib/turnAttach.test.ts b/lib/turnAttach.test.ts index 51d6bbab..b17fbad5 100644 --- a/lib/turnAttach.test.ts +++ b/lib/turnAttach.test.ts @@ -11,7 +11,10 @@ import { isAttachRunGone, lastUserText, ATTACH_FOLLOW_UP_NOTE, + ATTACH_FOLLOW_UP_DETACH_NOTE, + isAttachFollowUpHostNote, shouldPaintAttachFollowUpNote, + shouldPaintAttachFollowUpDetachNote, shouldRepostAttachFollowUp, shouldSkipAttachHotResume, prefixThroughLastUser, @@ -200,13 +203,20 @@ describe('HarnessHost attach wiring source-lock (plan #813 / adversarial #857)', expect(host).toContain('sendWhileRunning'); expect(host).toContain('ATTACH_FOLLOW_UP_NOTE'); expect(host).toContain('shouldPaintAttachFollowUpNote('); + expect(host).toContain('shouldPaintAttachFollowUpDetachNote('); expect(host).toContain('shouldRepostAttachFollowUp('); expect(host).toContain('shouldSkipAttachHotResume('); + expect(host).toContain('const operatorStop = shouldSkipAttachHotResume('); + expect(host).toContain('operatorStop,'); expect(host).toContain('runPromptRef.current(prompt, { pushUser: true })'); expect(host).toContain('setHostNote(ATTACH_FOLLOW_UP_NOTE)'); expect(host).toContain('bridge.pushMessage(MessageKind.System, ATTACH_FOLLOW_UP_NOTE)'); + expect(host).toContain('setHostNote(ATTACH_FOLLOW_UP_DETACH_NOTE)'); + expect(host).toContain('bridge.pushMessage(MessageKind.System, ATTACH_FOLLOW_UP_DETACH_NOTE)'); + expect(host).toContain('!operatorStop'); expect(host).not.toContain('} else if (sendWhileRunning) {'); - expect(host).toContain( + expect(host).toContain('isAttachFollowUpHostNote(hostNote)'); + expect(host).not.toContain( 'hostNote === ATTACH_FOLLOW_UP_NOTE ? teal.muted : ember.muted', ); }); @@ -247,6 +257,13 @@ describe('harnessChat attach hydrate source-lock (adversarial #857)', () => { it('ATTACH_FOLLOW_UP_NOTE is not a Turn-ended line (canvas System + TEAL host mirror)', () => { expect(ATTACH_FOLLOW_UP_NOTE).toMatch(/Follow-up not sent/); expect(ATTACH_FOLLOW_UP_NOTE).not.toMatch(/Turn ended/); + expect(ATTACH_FOLLOW_UP_DETACH_NOTE).toMatch(/Follow-up not sent/); + expect(ATTACH_FOLLOW_UP_DETACH_NOTE).toMatch(/detached/); + expect(ATTACH_FOLLOW_UP_DETACH_NOTE).not.toMatch(/still attached/); + expect(ATTACH_FOLLOW_UP_DETACH_NOTE).not.toMatch(/Turn ended/); + expect(isAttachFollowUpHostNote(ATTACH_FOLLOW_UP_NOTE)).toBe(true); + expect(isAttachFollowUpHostNote(ATTACH_FOLLOW_UP_DETACH_NOTE)).toBe(true); + expect(isAttachFollowUpHostNote('Request cancelled.')).toBe(false); }); }); @@ -290,6 +307,59 @@ describe('shouldPaintAttachFollowUpNote (adversarial #857 done-path lie)', () => }), ).toBe(false); }); + + it('does not paint the still-attached note on operator Stop (adversarial #857)', () => { + expect( + shouldPaintAttachFollowUpNote({ + sendWhileRunning: true, + resultOk: false, + turnStatus: 'running', + operatorStop: true, + }), + ).toBe(false); + }); +}); + +describe('shouldPaintAttachFollowUpDetachNote (adversarial #857 Stop lie)', () => { + it('paints Send-while-running + Stop while still running', () => { + expect( + shouldPaintAttachFollowUpDetachNote({ + sendWhileRunning: true, + operatorStop: true, + turnStatus: 'running', + }), + ).toBe(true); + }); + + it('does not paint EOF / 503 (operatorStop false)', () => { + expect( + shouldPaintAttachFollowUpDetachNote({ + sendWhileRunning: true, + operatorStop: false, + turnStatus: 'running', + }), + ).toBe(false); + }); + + it('does not paint kickColdAttach Stop (sendWhileRunning false)', () => { + expect( + shouldPaintAttachFollowUpDetachNote({ + sendWhileRunning: false, + operatorStop: true, + turnStatus: 'running', + }), + ).toBe(false); + }); + + it('does not paint after done / 404', () => { + expect( + shouldPaintAttachFollowUpDetachNote({ + sendWhileRunning: true, + operatorStop: true, + turnStatus: 'completed', + }), + ).toBe(false); + }); }); describe('shouldRepostAttachFollowUp (adversarial #857 remapped prompt)', () => { @@ -329,10 +399,26 @@ describe('shouldRepostAttachFollowUp (adversarial #857 remapped prompt)', () => it('is mutually exclusive with the follow-up note', () => { const running = { sendWhileRunning: true, resultOk: false, turnStatus: 'running' as const }; const done = { sendWhileRunning: true, resultOk: true, turnStatus: 'completed' as const }; + const stopped = { + sendWhileRunning: true, + resultOk: false, + turnStatus: 'running' as const, + operatorStop: true, + }; expect(shouldPaintAttachFollowUpNote(running)).toBe(true); expect(shouldRepostAttachFollowUp(running)).toBe(false); + expect(shouldPaintAttachFollowUpDetachNote({ ...running, operatorStop: false })).toBe(false); expect(shouldPaintAttachFollowUpNote(done)).toBe(false); expect(shouldRepostAttachFollowUp(done)).toBe(true); + expect(shouldPaintAttachFollowUpNote(stopped)).toBe(false); + expect(shouldRepostAttachFollowUp(stopped)).toBe(false); + expect( + shouldPaintAttachFollowUpDetachNote({ + sendWhileRunning: true, + operatorStop: true, + turnStatus: 'running', + }), + ).toBe(true); }); }); diff --git a/lib/turnAttach.ts b/lib/turnAttach.ts index 38cce24f..35c80931 100644 --- a/lib/turnAttach.ts +++ b/lib/turnAttach.ts @@ -26,21 +26,55 @@ export type HeapApplied = { runId: string; count: number }; export const ATTACH_FOLLOW_UP_NOTE = 'Follow-up not sent — still attached to the live run.'; +/** + * Send-while-running Stop (adversarial #857): GET is closed and auto-resume + * is skipped this tick. Same System/TEAL channel; not "still attached" (a + * lie) and not Turn ended · you stopped (would clear `running` / C15 409). + */ +export const ATTACH_FOLLOW_UP_DETACH_NOTE = + 'Follow-up not sent — detached from the live run.'; + +export function isAttachFollowUpHostNote(note: string | null | undefined): boolean { + return ( + note === ATTACH_FOLLOW_UP_NOTE || note === ATTACH_FOLLOW_UP_DETACH_NOTE + ); +} + /** * After Send-while-running attach returns: paint the follow-up note only when * the run is still live (D18 EOF / 503 subscribe-fail). Never after `done` * (`result.ok` + `completed` + Turn ended) — "still attached to the live run" - * would be a lie (adversarial #857). Not at remap: a System row before GET + * would be a lie (adversarial #857). Never on operator Stop (`operatorStop`) + * — the reader is closed and auto-resume is skipped; that path uses + * `ATTACH_FOLLOW_UP_DETACH_NOTE`. Not at remap: a System row before GET * would sit last on the ring (hot last-row snapshot / cold hydrate wipe). */ export function shouldPaintAttachFollowUpNote(input: { sendWhileRunning: boolean; resultOk: boolean; turnStatus?: TurnStatus; + operatorStop?: boolean; }): boolean { return ( input.sendWhileRunning && !input.resultOk && + input.turnStatus === 'running' && + !input.operatorStop + ); +} + +/** + * After Send-while-running attach Stop: GET closed, keep `running`, no + * auto-resume this tick. Distinct from the still-attached EOF/503 note. + */ +export function shouldPaintAttachFollowUpDetachNote(input: { + sendWhileRunning: boolean; + operatorStop: boolean; + turnStatus?: TurnStatus; +}): boolean { + return ( + input.sendWhileRunning && + input.operatorStop && input.turnStatus === 'running' ); }