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 }