From 813110a5a13643017326b60276d019574f37a8fc Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Wed, 19 Aug 2026 08:07:07 -0700 Subject: [PATCH] fix(resume): a replayed transcript had lost every tool result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resuming a session redrew its tool calls with nothing under them: ⏺ Read(src/domain/usage.ts) ⏺ Bash(ls -1 src | head -12) Every result the session had ever produced was gone. `rehydrate` folded `tool/call` into a row and never looked at `tool/result` at all, and the transcript builder discarded the row's text on purpose — reasonably, back when that text was the model-facing payload nobody wants pasted into a transcript. It no longer is. The card projections now carry a real `⎿` summary, and the harness persists each card's structured shape in the session log precisely so `presentResult` reproduces the identical card from a stored event. So replay presents its results the same way the live path does, and the row's text IS that summary: ⏺ Read(src/domain/usage.ts) ⎿ Read 3 lines ⏺ Bash(ls -1 src | head -12) ⎿ __tests__ app … `presentResult` takes the call's name and arguments as parameters instead of reading them off the live maps, which is what lets replay share it. Results are matched to their call by id, not by arrival order, because parallel calls interleave their results in the log. A write or edit cannot draw its diff card on the flat replay trail, so it gets the card's own header as text — `Wrote 3 lines to notes.txt`, `Added 1 line, removed 1 line` — rather than the "file has been updated successfully" boilerplate addressed to the model. Co-Authored-By: Claude Opus 5 --- package-lock.json | 4 +- package.json | 2 +- src/__tests__/harnessClient.test.ts | 5 +- src/__tests__/messages.test.ts | 5 +- src/domain/messages.ts | 5 +- src/harness/client.ts | 104 +++++++++++++++++++++++++--- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1319ed9..8643abf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-cctui", - "version": "0.3.13", + "version": "0.3.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-cctui", - "version": "0.3.13", + "version": "0.3.14", "license": "MIT", "dependencies": { "@dsh-cctui/ink": "file:./packages/dsh-cctui-ink", diff --git a/package.json b/package.json index 7004d28..38ffa06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-cctui", - "version": "0.3.13", + "version": "0.3.14", "description": "Claude-Code-style terminal UI for deepseek-harness, ported from clawcodex ui-tui", "license": "MIT", "repository": { diff --git a/src/__tests__/harnessClient.test.ts b/src/__tests__/harnessClient.test.ts index 370c23d..0f41ab9 100644 --- a/src/__tests__/harnessClient.test.ts +++ b/src/__tests__/harnessClient.test.ts @@ -74,6 +74,7 @@ function makeWorld() { const storedEvents = [ { data: { content: [{ text: 'old prompt', type: 'text' }], id: 'u1', role: 'user', source: { kind: 'user' } }, seq: 1, time: 1, type: 'user/message' }, { data: { arguments: '{"command":"ls"}', callId: 'c1', name: 'bash', step: 1, turn: 1 }, seq: 2, time: 2, type: 'tool/call' }, + { data: { message: { content: [{ content: [{ text: 'raw', type: 'text' }], toolCallId: 'c1', type: 'tool-result' }] }, step: 1, turn: 1 }, seq: 3, time: 3, type: 'tool/result' }, { data: { message: { content: [{ text: 'old reply', type: 'text' }], id: 'a1', role: 'assistant', source: { kind: 'model' } }, step: 1, turn: 1, usage: { cacheReadTokens: 100, inputTokens: 900, outputTokens: 120 } }, seq: 3, time: 3, type: 'assistant/message' }, { data: { reason: { kind: 'completed' }, turn: 1 }, seq: 4, time: 4, type: 'turn/end' } ] @@ -958,9 +959,11 @@ describe('HarnessGatewayClient', () => { expect((w.ctx.agents.resume as ReturnType).mock.calls).toHaveLength(1) expect(res.session_id).toBe('cc-resumed-1') expect(res.running).toBe(false) + // the tool row carries its PRESENTED result, so the resumed transcript + // reads the way it did the first time — `⎿ ran fine`, not a bare `⏺ Bash(ls)` expect(res.messages).toEqual([ { role: 'user', text: 'old prompt' }, - { context: 'ls', name: 'bash', role: 'tool' }, + { context: 'ls', name: 'bash', role: 'tool', text: 'ran fine' }, { role: 'assistant', text: 'old reply' } ]) diff --git a/src/__tests__/messages.test.ts b/src/__tests__/messages.test.ts index f8178a7..0f62a8e 100644 --- a/src/__tests__/messages.test.ts +++ b/src/__tests__/messages.test.ts @@ -14,7 +14,7 @@ describe('toTranscriptMessages', () => { it('preserves assistant tool-call rows so resume does not drop prior turns', () => { const rows = [ { role: 'user', text: 'first prompt' }, - { role: 'tool', context: 'repo', name: 'search_files', text: 'ignored raw result' }, + { role: 'tool', context: 'repo', name: 'search_files', text: 'Found 3 files' }, { role: 'assistant', text: 'first answer' }, { role: 'user', text: 'second prompt' } ] @@ -25,6 +25,9 @@ describe('toTranscriptMessages', () => { ['user', 'second prompt'] ]) expect(toTranscriptMessages(rows)[1]?.tools?.[0]).toContain('Search Files') + // the row's text is the PRESENTED result the live trail showed, so a + // resumed transcript reads the way it did the first time + expect(toTranscriptMessages(rows)[1]?.tools?.[0]).toContain('Found 3 files') }) }) diff --git a/src/domain/messages.ts b/src/domain/messages.ts index 99df7aa..7410abb 100644 --- a/src/domain/messages.ts +++ b/src/domain/messages.ts @@ -64,7 +64,10 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { const { context, name, role, text } = row as TranscriptRow if (role === 'tool') { - pending.push(buildToolTrailLine(name ?? 'tool', context ?? '')) + // `text` is the PRESENTED result — the same `⎿` summary the live trail + // built, not the model-facing payload — so a resumed transcript reads + // the way it did the first time. + pending.push(buildToolTrailLine(name ?? 'tool', context ?? '', false, typeof text === 'string' ? text : '')) continue } diff --git a/src/harness/client.ts b/src/harness/client.ts index 34a2cdd..fd5038a 100644 --- a/src/harness/client.ts +++ b/src/harness/client.ts @@ -123,6 +123,46 @@ const failureText = (message: string, identity?: { code: string; name: string }) return /^error\b/i.test(body) ? body : `Error: ${body}` } +/** + * A diff card's header, as plain text, for a transcript row that cannot draw + * the card itself — the resumed session's flat trail. Mirrors what DiffView + * prints above the patch (`Wrote N lines to path`, `Added N lines, removed M + * lines`), so replay says the same thing the live card did rather than the + * model-facing "the file has been updated successfully" boilerplate. + */ +const diffSummary = (diff: StructuredDiffPayload): string => { + if (diff.kind === 'create') { + const all = (diff.content ?? '').split('\n') + + if (all.at(-1) === '') { + all.pop() + } + + return `Wrote ${plural(all.length, 'line')} to ${diff.filePath}` + } + + let added = 0 + let removed = 0 + + for (const hunk of diff.hunks) { + for (const line of hunk.lines) { + if (line.startsWith('+')) { + added++ + } else if (line.startsWith('-')) { + removed++ + } + } + } + + if (!added && !removed) { + return '' + } + + const clauses = [added ? `Added ${plural(added, 'line')}` : '', removed ? `${added ? 'r' : 'R'}emoved ${plural(removed, 'line')}` : ''] + + return clauses.filter(Boolean).join(', ') +} + /** `1 file` / `2 files` — the count the ⎿ summary lines are built from. */ const plural = (count: number, one: string, many = `${one}s`) => `${count} ${count === 1 ? one : many}` @@ -482,6 +522,9 @@ export class HarnessGatewayClient extends GatewayClient { /** Fold a session event log into resume-transcript rows. */ private rehydrate(events: readonly SessionEvent[]): GatewayTranscriptMessage[] { const rows: GatewayTranscriptMessage[] = [] + // callId → the tool row waiting for its result. Keyed, not last-wins: + // parallel calls interleave their results in the log. + const calls = new Map() for (const event of events) { switch (event.type) { @@ -513,12 +556,41 @@ export class HarnessGatewayClient extends GatewayClient { } case 'tool/call': { - const { name, arguments: rawArgs } = (event as SessionEvent<'tool/call'>).data + const { callId, name, arguments: rawArgs } = (event as SessionEvent<'tool/call'>).data + calls.set(String(callId), { name, rawArgs, row: rows.length }) rows.push({ context: toolArgsPreview(rawArgs), name, role: 'tool' }) break } + case 'tool/result': { + // Replay's whole point is that the transcript reads the same coming + // back as it did going out. Without this the resumed session showed + // `⏺ Read(src/a.ts)` and nothing under it — every result the session + // had ever produced, gone. + const { message, error, meta } = (event as SessionEvent<'tool/result'>).data + const block = message.content[0] + const call = calls.get(String(block.toolCallId)) + + if (!call) { + break + } + + calls.delete(String(block.toolCallId)) + + const view = this.presentResult(call.name, call.rawArgs, block.content, Boolean(block.isError), meta) + const row = rows[call.row] + + if (row) { + row.text = + error || block.isError || view.failed + ? failureText(view.resultText, error) + : (view.structuredDiff && diffSummary(view.structuredDiff)) || view.resultText + } + + break + } + default: break } @@ -857,7 +929,18 @@ export class HarnessGatewayClient extends GatewayClient { const id = String(block.toolCallId) const startedAt = this.callStarted.get(id) const durationS = startedAt === undefined ? undefined : Math.max(0, Date.now() - startedAt) / 1000 - const view = this.delegationView(id, this.presentResult(id, block.content, Boolean(block.isError), meta, durationS), durationS) + const presented = this.presentResult( + this.callNames.get(id), + this.callRawArgs.get(id), + block.content, + Boolean(block.isError), + meta, + durationS + ) + + this.callRawArgs.delete(id) + + const view = this.delegationView(id, presented, durationS) const todos = this.pendingTodos this.pendingTodos = null @@ -1096,19 +1179,24 @@ export class HarnessGatewayClient extends GatewayClient { } - /** Refine a tool result through the tool's own presentation view. */ + /** + * Refine a tool result through the tool's own presentation view. + * + * The call's name and arguments come in rather than off the live maps, so + * REPLAY can use this too: the harness projects every card's structured + * shape through `output.presentationMeta`, which is persisted with the + * session log precisely so `presentResult` reproduces the identical card + * from a stored event. + */ private presentResult( - callId: string, + name: string | undefined, + rawArgs: string | undefined, content: readonly ContentBlock[], isError: boolean, meta: unknown, durationS?: number ): { failed?: boolean; resultRaw?: string; resultText: string; structuredDiff?: StructuredDiffPayload } { const fallback = textOf(content, ['text']) - const name = this.callNames.get(callId) - const rawArgs = this.callRawArgs.get(callId) - - this.callRawArgs.delete(callId) if (!name) { return { resultText: fallback }