import { describe, expect, it } from 'vitest' import { join as pathJoin, resolve as pathResolve } from 'node:path' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-title' import type { SessionNotification } from '@agentclientprotocol/sdk' import type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import FsLocal from '@deepseek-ai/dsh-fs-local' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts' const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = { schema: { type: 'null' }, render: () => [], } /** Collect the updates a single event produces (no presenter → generic fallback). */ function updatesFor(event: SessionEvent): SessionNotification['update'][] { const out: SessionNotification['update'][] = [] streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update)) return out } /** Collect the updates emitted by the live prompt stream (user echo suppressed). */ function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] { const out: SessionNotification['update'][] = [] streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false }) return out } /** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */ function registryOf(...tools: ToolDefinition[]): Pick { const map = new Map(tools.map(t => [t.name, t])) return { get: name => map.get(name) } } function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] { const out: SessionNotification['update'][] = [] for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter) return out } async function fsCtx(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FsLocal) await ctx.plugin(ToolFs) return ctx } function evt(type: T, data: Extract['data']): SessionEvent { return { type, seq: 0, time: 0, data } as SessionEvent } /** ACP path fields are filesystem paths; expectations use the host separator. */ function nativePath(...segments: string[]): string { return pathJoin(...segments) } /** Resolve root-relative fixtures the same way the bridge does on this host. */ function nativeAbsolute(...segments: string[]): string { return pathResolve(...segments) } describe('streamSessionEventUpdate', () => { it('maps a title event to session_info_update with the event timestamp', () => { expect(updatesFor({ type: 'session/title', seq: 3, time: 1_725_000_000_000, data: { title: 'Log-backed titles', messageSeqs: [1], source: { kind: 'fallback' }, }, })).toEqual([{ sessionUpdate: 'session_info_update', title: 'Log-backed titles', updatedAt: new Date(1_725_000_000_000).toISOString(), }]) }) it('maps assistant/chunk text-delta to agent_message_chunk', () => { expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } }))) .toEqual([{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }]) }) it('maps assistant/chunk reasoning-delta to agent_thought_chunk', () => { expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'mm' } }))) .toEqual([{ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'mm' } }]) }) it('produces no update for a non-text/reasoning chunk (e.g. block-start)', () => { expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } }))) .toEqual([]) }) it('marks retry and terminal model failure boundaries but not ordinary turn errors', () => { expect(updatesFor(evt('llm/retry', { turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 500, failure: { message: 'backend busy', code: 'SERVER' }, }))).toEqual([{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '\n\n[Previous model attempt discarded; retrying 1/2 in 500ms: backend busy]\n\n', }, }]) expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'error', step: 2, failure: { message: 'still busy', code: 'SERVER' } }, }))).toEqual([{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '\n\n[Model attempt failed; any partial output above is discarded: still busy]\n\n', }, }]) expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'error', step: 2, message: 'post-step failed' }, }))).toEqual([]) }) it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => { const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' })) expect(updates).toEqual([{ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'bash', // The fallback never sniffs a kind from the tool name — even a name a // first-party tool uses (`bash`) renders `other`; kinds are tool-owned // via presentCall. kind: 'other', status: 'in_progress', rawInput: { command: 'ls' }, }]) }) it('falls back to the raw argument string when tool arguments are not JSON', () => { const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: 'not json' }))[0] expect((update as { rawInput: unknown }).rawInput).toBe('not json') }) it('parses EMPTY tool arguments to an empty-object rawInput (a zero-arg call, not the raw-string fallback)', () => { // `JSON.parse('')` throws, so without the empty-string guard a zero-arg // call would render `rawInput: ''` via the non-JSON fallback; the guard // normalizes it to `{}`. const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'noop', arguments: '' }))[0] expect((update as { rawInput: unknown }).rawInput).toEqual({}) }) it('maps tool/result to completed/failed tool_call_update with text content', () => { const ok = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false })) expect(ok).toEqual([{ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [{ type: 'content', content: { type: 'text', text: 'out' } }], }]) const failed = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [], isError: true })) expect((failed[0] as { status: string }).status).toBe('failed') }) it('emits no execution update for a tool-result surface replacement', () => { const replacement = { ...evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: '[... tool result middle pruned ...]' }], isError: false, }), seq: 2, surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1], } as SessionEvent expect(updatesFor(replacement)).toEqual([]) }) it('drops non-text tool-result content (text-only)', () => { const update = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'reasoning', text: 'private' }], isError: false, }))[0] expect((update as { content: unknown[] }).content).toEqual([]) }) it('maps user/message text blocks to user_message_chunk (load replays the user side)', () => { // A text block surfaces; a non-text block (here a tool-call) is skipped, so // only the text chunk is emitted. expect(updatesFor(evt('user/message', { content: [ { type: 'text', text: 'hi' }, { type: 'tool-call', id: CallId('c'), name: 'bash', arguments: '{}' }, ], source: { kind: 'user' }, }))).toEqual([{ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hi' } }]) // A user/message with no text-bearing blocks produces no chunk. expect(updatesFor(evt('user/message', { content: [], source: { kind: 'user' } }))).toEqual([]) }) it('does not replay injected context as a direct user prompt', () => { expect(updatesFor(evt('user/message', { content: [{ type: 'text', text: 'internal context' }], source: { kind: 'plugin', plugin: 'reference' }, }))).toEqual([]) }) it('can suppress user/message chunks for live prompt turns', () => { expect(liveUpdatesFor(evt('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }))).toEqual([]) }) it('produces no update for boundary/other event types', () => { expect(updatesFor(evt('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))).toEqual([]) expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'completed' } }))).toEqual([]) expect(updatesFor(evt('step/start', { turn: 1, step: 1 }))).toEqual([]) }) it('maps todo/write to a plan sessionUpdate with priority synthesized as medium', () => { expect(updatesFor(evt('todo/write', { todos: [ { content: 'plan the work', status: 'in_progress' }, { content: 'write the code', status: 'pending' }, { content: 'run the tests', status: 'completed' }, ], }))).toEqual([{ sessionUpdate: 'plan', entries: [ { content: 'plan the work', priority: 'medium', status: 'in_progress' }, { content: 'write the code', priority: 'medium', status: 'pending' }, { content: 'run the tests', priority: 'medium', status: 'completed' }, ], }]) }) it('maps an empty todo list to a plan with no entries', () => { expect(updatesFor(evt('todo/write', { todos: [] }))).toEqual([{ sessionUpdate: 'plan', entries: [] }]) }) }) describe('todosToPlan', () => { it('maps status 1:1 and stamps every entry priority medium', () => { expect(todosToPlan([ { content: 'a', status: 'pending' }, { content: 'b', status: 'in_progress' }, { content: 'c', status: 'completed' }, ])).toEqual({ entries: [ { content: 'a', priority: 'medium', status: 'pending' }, { content: 'b', priority: 'medium', status: 'in_progress' }, { content: 'c', priority: 'medium', status: 'completed' }, ], }) }) }) describe('ToolPresenter (tool-owned presentation via the tool registry)', () => { /** A tool whose presentCall/presentResult return generic-card views. */ const bashLike: ToolDefinition = { name: 'bash', description: 'run a command', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: (args: unknown) => { const a = args as { command: string; description: string } return { card: 'generic', title: a.description, kind: 'execute', rawInput: a.command } }, presentResult: (_args: unknown, result: { content: { type: string }[] }) => ({ card: 'generic', content: [{ type: 'text', text: `wrapped:${result.content.length}` }], }), } it('tool/call uses the tool: description→title, command→rawInput, tool kind', () => { const presenter = new ToolPresenter(registryOf(bashLike)) const [update] = updatesWith(presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'ls -la', description: 'List files' }), })) expect(update).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'List files', kind: 'execute', status: 'in_progress', rawInput: 'ls -la', }) }) it('tool/result uses the tool to reformat content (resolved by the remembered tool/call)', () => { const presenter = new ToolPresenter(registryOf(bashLike)) const updates = updatesWith( presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }), evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }), ) expect(updates[1]).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [{ type: 'content', content: { type: 'text', text: 'wrapped:1' } }], }) }) it('a result with NO preceding call (unknown callId) falls back to the raw content', () => { const presenter = new ToolPresenter(registryOf(bashLike)) // No tool/call for c9 → presenter has nothing remembered → generic fallback. const [update] = updatesWith(presenter, evt('tool/result', { turn: 1, step: 1, callId: CallId('c9'), content: [{ type: 'text', text: 'raw' }], isError: false, })) expect(update).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'c9', status: 'completed', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }], }) }) it('a tool with no presentCall/presentResult gets the generic fallback (title = name)', () => { const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [] } const presenter = new ToolPresenter(registryOf(plain)) const [update] = updatesWith(presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'plain', arguments: '{"a":1}', })) expect(update).toMatchObject({ title: 'plain', kind: 'other', rawInput: { a: 1 } }) }) it('a presentation that omits kind/content/rawInput uses the defaults (kind other, raw result content kept)', () => { // A minimal tool-owned presentation: presentCall returns only a title (no // kind → defaults to `other`, no rawInput → omitted); presentResult returns // only a title (no content → the raw result content is kept). const minimal: ToolDefinition = { name: 'mini', description: 'm', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Doing a thing' }), presentResult: () => ({ card: 'generic', title: 'Did the thing' }), } const presenter = new ToolPresenter(registryOf(minimal)) const updates = updatesWith( presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'mini', arguments: '{}' }), evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'kept' }], isError: false }), ) // No kind → 'other'; no rawInput key at all. expect(updates[0]).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'Doing a thing', kind: 'other', status: 'in_progress' }) // Title replaced; content falls back to the raw result content. expect(updates[1]).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [{ type: 'content', content: { type: 'text', text: 'kept' } }], title: 'Did the thing', }) }) it('holds ONLY in-flight calls: the callId entry is removed once its result is presented', () => { const presenter = new ToolPresenter(registryOf(bashLike)) updatesWith( presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }), evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'o' }], isError: false }), ) // A SECOND result for the same callId now finds nothing remembered, so it // falls back to raw content (proving the first result consumed the entry — // the map does not retain finished calls). const [late] = updatesWith(presenter, evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'late' }], isError: false, })) expect(late).toMatchObject({ content: [{ type: 'content', content: { type: 'text', text: 'late' } }] }) }) it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => { // A buggy tool whose display callbacks throw must not fail a live turn or a session/load // replay (docs/defensive-patterns.md "contain callback exceptions at the boundary"). The // presenter reports the error and falls back to generic rendering. const boom: ToolDefinition = { name: 'boom', description: 'b', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => { throw new Error('call boom') }, presentResult: () => { throw new Error('result boom') }, } const errors: string[] = [] const presenter = new ToolPresenter(registryOf(boom), msg => errors.push(msg)) const updates = updatesWith( presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{"a":1}' }), evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }), ) // tool/call fell back to title=name, raw args as rawInput. expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom', kind: 'other', rawInput: { a: 1 } }) // tool/result fell back to the raw content. expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] }) // Both throws were reported, not propagated. expect(errors).toHaveLength(2) expect(errors[0]).toContain('presentCall threw') expect(errors[1]).toContain('presentResult threw') }) it('contains a throwing presenter even with the DEFAULT (no-op) onError sink', () => { // Constructed without an onError sink (the default `() => {}`): a throwing // presenter is still swallowed and falls back generically — the absence of a // logger must not turn a display bug into a propagated exception. const boom: ToolDefinition = { name: 'boom', description: 'b', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => { throw new Error('call boom') }, presentResult: () => { throw new Error('result boom') }, } const presenter = new ToolPresenter(registryOf(boom)) const updates = updatesWith( presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{}' }), evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }), ) expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom' }) expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] }) }) it('an unknown render-intent card throws via the exhaustiveness guard (closed union)', () => { // The bridge switches on `view.card` and ends with assertNever: a rogue card // (only reachable by a cast — the union is closed) must throw, so adding a // real variant later fails to compile at the switch instead of silently // dropping the card. const rogue: ToolDefinition = { name: 'rogue', description: 'r', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], // A card value outside the union — forced with a cast (no valid input reaches this). presentCall: () => ({ card: 'chart', title: 'nope' }) as unknown as ReturnType>, } const presenter = new ToolPresenter(registryOf(rogue)) expect(() => updatesWith(presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}', }))).toThrow('unreachable variant') }) it('an unknown render-intent RESULT card throws via the exhaustiveness guard (closed union)', () => { // The result-side renderer is also an exhaustive switch + assertNever: a rogue // result card (only reachable by a cast) must throw, so adding a real result // variant later fails to compile at the switch. const rogue: ToolDefinition = { name: 'rogue', description: 'r', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'r' }), presentResult: () => ({ card: 'chart' }) as unknown as ReturnType>, } const presenter = new ToolPresenter(registryOf(rogue)) expect(() => updatesWith( presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}' }), evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }), )).toThrow('unreachable variant') }) it('forwards fs-tool render intents onto the wire (REAL read → generic locations, edit → diff content)', async () => { // Use the SHIPPING fs tools (not a stand-in), booted through their real // plugins, so the wire tool_call carries the actual presentCall output — // read's follow-along `locations` and edit's `diff` content block. (docs/testing.md // "prefer the real implementation over a mock".) const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FsLocal) await ctx.plugin(ToolFs) const presenter = new ToolPresenter(ctx.tools) const [readCall] = updatesWith(presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('r1'), name: 'read', arguments: JSON.stringify({ file_path: 'src/a.ts', offset: 12 }), })) // A generic card: the read window is in the title, the offset drives the // follow-along location line. No rawInput (the window lives in the title). expect(readCall).toMatchObject({ sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'Read src/a.ts (from line 12)', kind: 'read', locations: [{ path: 'src/a.ts', line: 12 }], }) expect((readCall as { rawInput?: unknown }).rawInput).toBeUndefined() const [editCall] = updatesWith(presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: JSON.stringify({ file_path: 'src/b.ts', old_string: 'x', new_string: 'y' }), })) // A diff card: `edit` kind, a `{ type: 'diff' }` content block carrying the // literal old→new replacement, plus the follow-along location. expect(editCall).toMatchObject({ sessionUpdate: 'tool_call', toolCallId: 'e1', title: 'Edit src/b.ts', kind: 'edit', locations: [{ path: 'src/b.ts' }], content: [{ type: 'diff', path: 'src/b.ts', oldText: 'x', newText: 'y' }], }) await ctx.fiber.dispose() }) }) describe('terminal-card mapping (capability-gated)', () => { // A tool that renders as a terminal — a stand-in for tool-bash's shape, letting // us drive the bridge's terminal mapping without the real executor. `callCard` // selects a terminal call view (optionally with a cwd) or a generic one (for the // orphan-guard test); `resultTerminal` is the terminal result view's output/exit. type CallCard = { card: 'terminal'; cwd?: string } | { card: 'generic' } type ResultTerm = { title?: string; output?: string; exitCode?: number; signal?: string } const termTool = (callCard: CallCard, resultTerminal: ResultTerm): ToolDefinition => ({ name: 'bash', description: 'run a command', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: (args: unknown) => { const command = (args as { command: string }).command const description = (args as { description: string }).description if (callCard.card === 'terminal') { return { card: 'terminal', title: command, description, ...callCard.cwd !== undefined ? { cwd: callCard.cwd } : {} } } return { card: 'generic', title: command, kind: 'execute', rawInput: command, content: [{ type: 'text', text: description }] } }, presentResult: () => ({ card: 'terminal', ...resultTerminal }), }) const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) }) const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false }) const prunedResultEvent = { ...resultEvent, seq: 2, data: { ...resultEvent.data, content: [{ type: 'text', text: '[... tool result middle pruned ...]' }], }, surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1], } as SessionEvent function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] { const presenter = new ToolPresenter(registryOf(tool)) const out: SessionNotification['update'][] = [] for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, { enabled, cwd }) return out } it('capability ON: description content THEN terminal block; cwd from the session header when the tool gives none', () => { const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent) expect(call).toMatchObject({ sessionUpdate: 'tool_call', content: [ { type: 'content', content: { type: 'text', text: 'Greet' } }, { type: 'terminal', terminalId: 'c1' }, ], _meta: { terminal_info: { terminal_id: 'c1', cwd: '/work/proj' } }, }) // The update OMITS content (it would clobber the terminal block) and carries output + exit. expect(update).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', _meta: { terminal_output: { terminal_id: 'c1', data: 'hi\n' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } }, }) }) it('live/replay translation preserves the original terminal completion across a pruning rewrite', () => { const updates = termUpdates( termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent, prunedResultEvent, ) expect(updates).toHaveLength(2) expect(updates[1]).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', _meta: { terminal_output: { terminal_id: 'c1', data: 'hi\n' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 }, }, }) }) it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => { const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent) expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs') const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: nativePath('sub', 'dir') }, { output: 'x' }), true, nativeAbsolute('/work/proj'), callEvent) // Relative workdir resolved against the session cwd — the card header matches // where execution actually ran (tool-bash resolves the same way). expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe(nativeAbsolute('/work/proj', 'sub', 'dir')) // No session cwd to resolve against → the relative tool cwd is passed through as-is. const [noSessionCwd] = termUpdates(termTool({ card: 'terminal', cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent) expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only') }) it('capability ON: a signal kill maps to terminal_exit.signal', () => { const [, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent) expect((update as unknown as { _meta: { terminal_exit: unknown } })._meta.terminal_exit).toEqual({ terminal_id: 'c1', signal: 'SIGKILL' }) }) it('capability ON: a terminal result with output but NO exit/signal emits terminal_output and NO exit pill', () => { // A terminal-rendering tool that reports no structured exit (neither exitCode // nor signal) — the card shows output but no exit pill. const [, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'partial' }), true, '/w', callEvent, resultEvent) const meta = (update as unknown as { _meta: { terminal_output?: unknown; terminal_exit?: unknown } })._meta expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'partial' }) expect(meta.terminal_exit).toBeUndefined() }) it('capability OFF: no terminal block or _meta; the description content and the bridge-derived fenced result render', () => { const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent) expect(call).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'echo hi', kind: 'execute', status: 'in_progress', rawInput: 'echo hi', content: [{ type: 'content', content: { type: 'text', text: 'Greet' } }], }) // The bridge derives the fenced ```console fallback from the terminal output. expect(update).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }], }) }) it('orphan guard: a result-side terminal with a GENERIC call is dropped (no orphan terminal_output)', () => { // presentCall is a generic card, but presentResult returns a terminal view — // the bridge must not emit _meta.terminal_output for a terminal Zed never made. const [call, update] = termUpdates(termTool({ card: 'generic' }, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent) // The call was generic → ordinary tool_call (description content, no _meta). expect((call as { _meta?: unknown })._meta).toBeUndefined() expect((call as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'Greet' } }]) // The result falls back to the RAW result content (the tool/result event's text); NO terminal _meta. expect((update as { _meta?: unknown })._meta).toBeUndefined() expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'hi\n' } }]) }) it('capability ON: a terminal result title replaces the completed-card title; missing output emits empty data', () => { // A terminal result MAY carry a replacement title and MAY omit output (a run // that produced nothing) — the _meta carries empty data, not a dropped key. const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', exitCode: 0 }), true, '/w', callEvent, resultEvent) expect(update).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', title: 'Ran echo', _meta: { terminal_output: { terminal_id: 'c1', data: '' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } }, }) }) it('capability OFF: a terminal result title rides on the fenced fallback update', () => { const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', output: 'hi\n' }), false, '/w', callEvent, resultEvent) expect(update).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }], title: 'Ran echo', }) }) it('a terminal call with NO description and NO capability is a bare execute card (no content key)', () => { // A terminal view whose presentCall omits `description`, with the capability // OFF: no description block and no terminal block → the card carries no content. const noDesc: ToolDefinition = { name: 'bash', description: 'run a command', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: (args: unknown) => ({ card: 'terminal', title: (args as { command: string }).command }), } const [call] = termUpdates(noDesc, false, undefined, callEvent) expect(call).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'echo hi', kind: 'execute', status: 'in_progress', rawInput: 'echo hi', }) }) }) describe('diff-card mapping', () => { // A stand-in diff tool, letting us drive the bridge's diff arm across shapes // the shipping fs tools don't emit (no locations, empty diffs). const diffTool = (view: unknown): ToolDefinition => ({ name: 'writer', description: 'writes a file', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => view as ReturnType>, }) function callUpdate(tool: ToolDefinition, cwd: string | undefined): SessionNotification['update'] { const presenter = new ToolPresenter(registryOf(tool)) const out: SessionNotification['update'][] = [] streamSessionEventUpdate( SessionId('s1'), evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'writer', arguments: '{}' }), n => out.push(n.update), presenter, { enabled: false, cwd }, ) return out[0]! } it('a diff with NO locations relativizes the title off the first diff path; omits the locations key', () => { const update = callUpdate(diffTool({ card: 'diff', title: 'Write /work/proj/a.txt', diffs: [{ path: '/work/proj/a.txt', oldText: null, newText: 'x' }] }), '/work/proj') expect(update).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'Write a.txt', kind: 'edit', status: 'in_progress', content: [{ type: 'diff', path: '/work/proj/a.txt', oldText: null, newText: 'x' }], }) }) it('a diff with an EMPTY diffs array omits the content key (no diff blocks to send)', () => { const update = callUpdate(diffTool({ card: 'diff', title: 'Write nothing', diffs: [] }), undefined) expect(update).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'Write nothing', kind: 'edit', status: 'in_progress', }) }) }) describe('result-time diff card (REAL fs edit tool → tool_call_update diff blocks)', () => { // Drive the SHIPPING fs edit tool through the bridge: the pending tool/call installs the // call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`, // which presentResult narrows into a `diff` result card the bridge forwards as `{ type: // 'diff' }` content blocks. The real tool is required because its result metadata is the contract. it('live/replay translation keeps the applied diff when a pruning rewrite follows', async () => { const ctx = await fsCtx() const presenter = new ToolPresenter(ctx.tools) const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' }) // The applied hunk the tool would compute and persist on the result meta. const meta = { diffs: [{ path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] } const originalResult = evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta, }) const replacement = { ...originalResult, seq: 3, data: { ...originalResult.data, content: [{ type: 'text', text: '[... tool result middle pruned ...]' }], }, surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2], } as SessionEvent const updates = updatesWith( presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }), originalResult, replacement, ) expect(updates).toHaveLength(2) const resultUpdate = updates[1] expect(resultUpdate).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'e1', status: 'completed', title: 'Edit src/b.ts', content: [{ type: 'diff', path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }], }) await ctx.fiber.dispose() }) it('an error result carries NO diff card (falls back to raw content)', async () => { const ctx = await fsCtx() const presenter = new ToolPresenter(ctx.tools) const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' }) const [, resultUpdate] = updatesWith( presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }), evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'Error: boom' }], isError: true }), ) expect(resultUpdate).toMatchObject({ sessionUpdate: 'tool_call_update', status: 'failed' }) expect(resultUpdate).not.toHaveProperty('content', expect.arrayContaining([expect.objectContaining({ type: 'diff' })])) await ctx.fiber.dispose() }) it('the completed diff TITLE relativizes against the session cwd (the result title replaces the card header)', async () => { // A `tool_call_update.title` replaces the card header, so the result-side diff must // relativize its title exactly as the pending card did — otherwise a completed // absolute-path edit flips `Edit src/b.ts` back to the raw absolute path. Diff and location // paths remain absolute so the editor can open the real file. const ctx = await fsCtx() const presenter = new ToolPresenter(ctx.tools) const workspace = nativeAbsolute('/work/proj') const file = nativeAbsolute('/work/proj', 'src', 'b.ts') const args = JSON.stringify({ file_path: file, old_string: 'OLD', new_string: 'NEW' }) const meta = { diffs: [{ path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] } const out: SessionNotification['update'][] = [] const rendering = { enabled: false, cwd: workspace } for (const event of [ evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }), evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }), ]) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, rendering) expect(out[1]).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'e1', status: 'completed', title: `Edit ${nativePath('src', 'b.ts')}`, content: [{ type: 'diff', path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }], }) await ctx.fiber.dispose() }) it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => { // Shipping edit always has a hunk and write falls back to a whole-file diff, so a synthetic // tool is required to cover both absent-title and empty-content result branches. const emptyDiffTool: ToolDefinition = { name: 'writer', description: 'writes a file', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'diff', title: 'Write x', diffs: [{ path: 'x', oldText: null, newText: 'y' }] }), presentResult: () => ({ card: 'diff', diffs: [] }), } const presenter = new ToolPresenter(registryOf(emptyDiffTool)) const [, resultUpdate] = updatesWith( presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('w1'), name: 'writer', arguments: '{}' }), evt('tool/result', { turn: 1, step: 1, callId: CallId('w1'), content: [{ type: 'text', text: 'ok' }], isError: false }), ) expect(resultUpdate).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'w1', status: 'completed', }) expect(resultUpdate).not.toHaveProperty('content') expect(resultUpdate).not.toHaveProperty('title') }) }) describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => { // The bridge relativizes a file card's TITLE against the session workspace cwd (mirroring the // reference adapter's `toDisplayPath`), while leaving location/diff paths raw. Use real fs tools // and the absolute paths an editor supplies; presentation itself is args-only and lacks cwd. function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] { const presenter = new ToolPresenter(ctx.tools) const out: SessionNotification['update'][] = [] streamSessionEventUpdate( SessionId('s1'), evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name, arguments: JSON.stringify(args) }), n => out.push(n.update), presenter, { enabled: false, cwd: sessionCwd }, ) return out[0]! } it('read: an absolute path inside the workspace relativizes the TITLE; the location path stays absolute', async () => { const ctx = await fsCtx() const workspace = nativeAbsolute('/work/proj') const file = nativeAbsolute('/work/proj', 'src', 'a.ts') const update = callUpdate(ctx, workspace, 'read', { file_path: file, offset: 5 }) expect(update).toMatchObject({ title: `Read ${nativePath('src', 'a.ts')} (from line 5)`, locations: [{ path: file, line: 5 }], }) await ctx.fiber.dispose() }) it('edit: the diff TITLE relativizes; the diff/location paths stay absolute (the editor opens the real path)', async () => { const ctx = await fsCtx() const workspace = nativeAbsolute('/work/proj') const file = nativeAbsolute('/work/proj', 'src', 'b.ts') const update = callUpdate(ctx, workspace, 'edit', { file_path: file, old_string: 'x', new_string: 'y' }) expect(update).toMatchObject({ title: `Edit ${nativePath('src', 'b.ts')}`, locations: [{ path: file }], content: [{ type: 'diff', path: file, oldText: 'x', newText: 'y' }], }) await ctx.fiber.dispose() }) it('a path OUTSIDE the workspace is left as-is (no `..` title)', async () => { const ctx = await fsCtx() const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/etc/passwd' }) expect((update as { title: string }).title).toBe('Read /etc/passwd') await ctx.fiber.dispose() }) it('an in-workspace file whose relative form starts with `..` chars (a sibling name) still relativizes', async () => { // `/work/proj/..cache/x` is inside the workspace — its relative form `..cache/x` begins // with the chars `..` but is not a parent segment. Segment-aware guarding must relativize it, // matching targets under `cwd + sep` in the reference adapter. const ctx = await fsCtx() const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativeAbsolute('/work/proj', '..cache', 'x.ts') }) expect((update as { title: string }).title).toBe(`Read ${nativePath('..cache', 'x.ts')}`) await ctx.fiber.dispose() }) it('no session cwd → the absolute title is left unchanged', async () => { const ctx = await fsCtx() const update = callUpdate(ctx, undefined, 'read', { file_path: '/work/proj/src/a.ts' }) expect((update as { title: string }).title).toBe('Read /work/proj/src/a.ts') await ctx.fiber.dispose() }) it('a relative path is passed through unchanged (already display-friendly)', async () => { const ctx = await fsCtx() const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativePath('src', 'a.ts') }) expect((update as { title: string }).title).toBe(`Read ${nativePath('src', 'a.ts')}`) await ctx.fiber.dispose() }) }) describe('agentOptions', () => { it('includes only the fields present in config', () => { expect(agentOptions({})).toEqual({}) expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' }) expect(agentOptions({ provider: 'p', model: 'm' })).toEqual({ provider: 'p', model: 'm' }) }) })