stream-update.spec.ts 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955
  1. import { describe, expect, it } from 'vitest'
  2. import { join as pathJoin, resolve as pathResolve } from 'node:path'
  3. import { Context } from 'cordis'
  4. import { CallId } from '@deepseek-ai/dsh-llm'
  5. import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  6. import type {} from '@deepseek-ai/dsh-session-title'
  7. import type { SessionNotification } from '@agentclientprotocol/sdk'
  8. import type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools'
  9. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  10. import ToolRegistry from '@deepseek-ai/dsh-tools'
  11. import FsLocal from '@deepseek-ai/dsh-fs-local'
  12. import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
  13. import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts'
  14. const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
  15. schema: { type: 'null' },
  16. render: () => [],
  17. }
  18. /** Collect the updates a single event produces (no presenter → generic fallback). */
  19. function updatesFor(event: SessionEvent): SessionNotification['update'][] {
  20. const out: SessionNotification['update'][] = []
  21. streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update))
  22. return out
  23. }
  24. /** Collect the updates emitted by the live prompt stream (user echo suppressed). */
  25. function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
  26. const out: SessionNotification['update'][] = []
  27. streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false })
  28. return out
  29. }
  30. /** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */
  31. function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistryType, 'get'> {
  32. const map = new Map(tools.map(t => [t.name, t]))
  33. return { get: name => map.get(name) }
  34. }
  35. function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
  36. const out: SessionNotification['update'][] = []
  37. for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
  38. return out
  39. }
  40. async function fsCtx(): Promise<Context> {
  41. const ctx = new Context()
  42. await ctx.plugin(SystemPrompt)
  43. await ctx.plugin(ToolRegistry)
  44. await ctx.plugin(FsLocal)
  45. await ctx.plugin(ToolFs)
  46. return ctx
  47. }
  48. function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent, { type: T }>['data']): SessionEvent {
  49. return { type, seq: 0, time: 0, data } as SessionEvent
  50. }
  51. /** ACP path fields are filesystem paths; expectations use the host separator. */
  52. function nativePath(...segments: string[]): string {
  53. return pathJoin(...segments)
  54. }
  55. /** Resolve root-relative fixtures the same way the bridge does on this host. */
  56. function nativeAbsolute(...segments: string[]): string {
  57. return pathResolve(...segments)
  58. }
  59. describe('streamSessionEventUpdate', () => {
  60. it('maps a title event to session_info_update with the event timestamp', () => {
  61. expect(updatesFor({
  62. type: 'session/title',
  63. seq: 3,
  64. time: 1_725_000_000_000,
  65. data: {
  66. title: 'Log-backed titles',
  67. messageSeqs: [1],
  68. source: { kind: 'fallback' },
  69. },
  70. })).toEqual([{
  71. sessionUpdate: 'session_info_update',
  72. title: 'Log-backed titles',
  73. updatedAt: new Date(1_725_000_000_000).toISOString(),
  74. }])
  75. })
  76. it('maps assistant/chunk text-delta to agent_message_chunk', () => {
  77. expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })))
  78. .toEqual([{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }])
  79. })
  80. it('maps assistant/chunk reasoning-delta to agent_thought_chunk', () => {
  81. expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'mm' } })))
  82. .toEqual([{ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'mm' } }])
  83. })
  84. it('produces no update for a non-text/reasoning chunk (e.g. block-start)', () => {
  85. expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } })))
  86. .toEqual([])
  87. })
  88. it('marks retry and terminal model failure boundaries but not ordinary turn errors', () => {
  89. expect(updatesFor(evt('llm/retry', {
  90. turn: 1,
  91. step: 1,
  92. retry: 1,
  93. maxRetries: 2,
  94. delayMs: 500,
  95. failure: { message: 'backend busy', code: 'SERVER' },
  96. }))).toEqual([{
  97. sessionUpdate: 'agent_message_chunk',
  98. content: {
  99. type: 'text',
  100. text: '\n\n[Previous model attempt discarded; retrying 1/2 in 500ms: backend busy]\n\n',
  101. },
  102. }])
  103. expect(updatesFor(evt('turn/end', {
  104. turn: 1,
  105. reason: { kind: 'error', step: 2, failure: { message: 'still busy', code: 'SERVER' } },
  106. }))).toEqual([{
  107. sessionUpdate: 'agent_message_chunk',
  108. content: {
  109. type: 'text',
  110. text: '\n\n[Model attempt failed; any partial output above is discarded: still busy]\n\n',
  111. },
  112. }])
  113. expect(updatesFor(evt('turn/end', {
  114. turn: 1,
  115. reason: { kind: 'error', step: 2, message: 'post-step failed' },
  116. }))).toEqual([])
  117. })
  118. it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => {
  119. const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }))
  120. expect(updates).toEqual([{
  121. sessionUpdate: 'tool_call',
  122. toolCallId: 'c1',
  123. title: 'bash',
  124. // The fallback never sniffs a kind from the tool name — even a name a
  125. // first-party tool uses (`bash`) renders `other`; kinds are tool-owned
  126. // via presentCall.
  127. kind: 'other',
  128. status: 'in_progress',
  129. rawInput: { command: 'ls' },
  130. }])
  131. })
  132. it('falls back to the raw argument string when tool arguments are not JSON', () => {
  133. const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: 'not json' }))[0]
  134. expect((update as { rawInput: unknown }).rawInput).toBe('not json')
  135. })
  136. it('parses EMPTY tool arguments to an empty-object rawInput (a zero-arg call, not the raw-string fallback)', () => {
  137. // `JSON.parse('')` throws, so without the empty-string guard a zero-arg
  138. // call would render `rawInput: ''` via the non-JSON fallback; the guard
  139. // normalizes it to `{}`.
  140. const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'noop', arguments: '' }))[0]
  141. expect((update as { rawInput: unknown }).rawInput).toEqual({})
  142. })
  143. it('maps tool/result to completed/failed tool_call_update with text content', () => {
  144. const ok = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }))
  145. expect(ok).toEqual([{
  146. sessionUpdate: 'tool_call_update',
  147. toolCallId: 'c1',
  148. status: 'completed',
  149. content: [{ type: 'content', content: { type: 'text', text: 'out' } }],
  150. }])
  151. const failed = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [], isError: true }))
  152. expect((failed[0] as { status: string }).status).toBe('failed')
  153. })
  154. it('emits no execution update for a tool-result surface replacement', () => {
  155. const replacement = {
  156. ...evt('tool/result', {
  157. turn: 1,
  158. step: 1,
  159. callId: CallId('c1'),
  160. content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
  161. isError: false,
  162. }),
  163. seq: 2,
  164. surfaceOp: { op: 'replace', start: 1, end: 1 },
  165. sourceEventSeqs: [1],
  166. } as SessionEvent
  167. expect(updatesFor(replacement)).toEqual([])
  168. })
  169. it('drops non-text tool-result content (text-only)', () => {
  170. const update = updatesFor(evt('tool/result', {
  171. turn: 1, step: 1, callId: CallId('c1'),
  172. content: [{ type: 'reasoning', text: 'private' }],
  173. isError: false,
  174. }))[0]
  175. expect((update as { content: unknown[] }).content).toEqual([])
  176. })
  177. it('maps user/message text blocks to user_message_chunk (load replays the user side)', () => {
  178. // A text block surfaces; a non-text block (here a tool-call) is skipped, so
  179. // only the text chunk is emitted.
  180. expect(updatesFor(evt('user/message', {
  181. content: [
  182. { type: 'text', text: 'hi' },
  183. { type: 'tool-call', id: CallId('c'), name: 'bash', arguments: '{}' },
  184. ],
  185. source: { kind: 'user' },
  186. }))).toEqual([{ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hi' } }])
  187. // A user/message with no text-bearing blocks produces no chunk.
  188. expect(updatesFor(evt('user/message', { content: [], source: { kind: 'user' } }))).toEqual([])
  189. })
  190. it('does not replay injected context as a direct user prompt', () => {
  191. expect(updatesFor(evt('user/message', {
  192. content: [{ type: 'text', text: 'internal context' }],
  193. source: { kind: 'plugin', plugin: 'reference' },
  194. }))).toEqual([])
  195. })
  196. it('can suppress user/message chunks for live prompt turns', () => {
  197. expect(liveUpdatesFor(evt('user/message', {
  198. content: [{ type: 'text', text: 'hi' }],
  199. source: { kind: 'user' },
  200. }))).toEqual([])
  201. })
  202. it('produces no update for boundary/other event types', () => {
  203. expect(updatesFor(evt('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))).toEqual([])
  204. expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'completed' } }))).toEqual([])
  205. expect(updatesFor(evt('step/start', { turn: 1, step: 1 }))).toEqual([])
  206. })
  207. it('maps todo/write to a plan sessionUpdate with priority synthesized as medium', () => {
  208. expect(updatesFor(evt('todo/write', {
  209. todos: [
  210. { content: 'plan the work', status: 'in_progress' },
  211. { content: 'write the code', status: 'pending' },
  212. { content: 'run the tests', status: 'completed' },
  213. ],
  214. }))).toEqual([{
  215. sessionUpdate: 'plan',
  216. entries: [
  217. { content: 'plan the work', priority: 'medium', status: 'in_progress' },
  218. { content: 'write the code', priority: 'medium', status: 'pending' },
  219. { content: 'run the tests', priority: 'medium', status: 'completed' },
  220. ],
  221. }])
  222. })
  223. it('maps an empty todo list to a plan with no entries', () => {
  224. expect(updatesFor(evt('todo/write', { todos: [] }))).toEqual([{ sessionUpdate: 'plan', entries: [] }])
  225. })
  226. })
  227. describe('todosToPlan', () => {
  228. it('maps status 1:1 and stamps every entry priority medium', () => {
  229. expect(todosToPlan([
  230. { content: 'a', status: 'pending' },
  231. { content: 'b', status: 'in_progress' },
  232. { content: 'c', status: 'completed' },
  233. ])).toEqual({
  234. entries: [
  235. { content: 'a', priority: 'medium', status: 'pending' },
  236. { content: 'b', priority: 'medium', status: 'in_progress' },
  237. { content: 'c', priority: 'medium', status: 'completed' },
  238. ],
  239. })
  240. })
  241. })
  242. describe('ToolPresenter (tool-owned presentation via the tool registry)', () => {
  243. /** A tool whose presentCall/presentResult return generic-card views. */
  244. const bashLike: ToolDefinition = {
  245. name: 'bash',
  246. description: 'run a command',
  247. parameters: {},
  248. output: UNUSED_TOOL_OUTPUT,
  249. execute: async () => [],
  250. presentCall: (args: unknown) => {
  251. const a = args as { command: string; description: string }
  252. return { card: 'generic', title: a.description, kind: 'execute', rawInput: a.command }
  253. },
  254. presentResult: (_args: unknown, result: { content: { type: string }[] }) => ({
  255. card: 'generic',
  256. content: [{ type: 'text', text: `wrapped:${result.content.length}` }],
  257. }),
  258. }
  259. it('tool/call uses the tool: description→title, command→rawInput, tool kind', () => {
  260. const presenter = new ToolPresenter(registryOf(bashLike))
  261. const [update] = updatesWith(presenter, evt('tool/call', {
  262. turn: 1, step: 1, callId: CallId('c1'), name: 'bash',
  263. arguments: JSON.stringify({ command: 'ls -la', description: 'List files' }),
  264. }))
  265. expect(update).toEqual({
  266. sessionUpdate: 'tool_call',
  267. toolCallId: 'c1',
  268. title: 'List files',
  269. kind: 'execute',
  270. status: 'in_progress',
  271. rawInput: 'ls -la',
  272. })
  273. })
  274. it('tool/result uses the tool to reformat content (resolved by the remembered tool/call)', () => {
  275. const presenter = new ToolPresenter(registryOf(bashLike))
  276. const updates = updatesWith(
  277. presenter,
  278. evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }),
  279. evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }),
  280. )
  281. expect(updates[1]).toEqual({
  282. sessionUpdate: 'tool_call_update',
  283. toolCallId: 'c1',
  284. status: 'completed',
  285. content: [{ type: 'content', content: { type: 'text', text: 'wrapped:1' } }],
  286. })
  287. })
  288. it('a result with NO preceding call (unknown callId) falls back to the raw content', () => {
  289. const presenter = new ToolPresenter(registryOf(bashLike))
  290. // No tool/call for c9 → presenter has nothing remembered → generic fallback.
  291. const [update] = updatesWith(presenter, evt('tool/result', {
  292. turn: 1, step: 1, callId: CallId('c9'), content: [{ type: 'text', text: 'raw' }], isError: false,
  293. }))
  294. expect(update).toEqual({
  295. sessionUpdate: 'tool_call_update',
  296. toolCallId: 'c9',
  297. status: 'completed',
  298. content: [{ type: 'content', content: { type: 'text', text: 'raw' } }],
  299. })
  300. })
  301. it('a tool with no presentCall/presentResult gets the generic fallback (title = name)', () => {
  302. const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [] }
  303. const presenter = new ToolPresenter(registryOf(plain))
  304. const [update] = updatesWith(presenter, evt('tool/call', {
  305. turn: 1, step: 1, callId: CallId('c1'), name: 'plain', arguments: '{"a":1}',
  306. }))
  307. expect(update).toMatchObject({ title: 'plain', kind: 'other', rawInput: { a: 1 } })
  308. })
  309. it('a presentation that omits kind/content/rawInput uses the defaults (kind other, raw result content kept)', () => {
  310. // A minimal tool-owned presentation: presentCall returns only a title (no
  311. // kind → defaults to `other`, no rawInput → omitted); presentResult returns
  312. // only a title (no content → the raw result content is kept).
  313. const minimal: ToolDefinition = {
  314. name: 'mini',
  315. description: 'm',
  316. parameters: {},
  317. output: UNUSED_TOOL_OUTPUT,
  318. execute: async () => [],
  319. presentCall: () => ({ card: 'generic', title: 'Doing a thing' }),
  320. presentResult: () => ({ card: 'generic', title: 'Did the thing' }),
  321. }
  322. const presenter = new ToolPresenter(registryOf(minimal))
  323. const updates = updatesWith(
  324. presenter,
  325. evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'mini', arguments: '{}' }),
  326. evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'kept' }], isError: false }),
  327. )
  328. // No kind → 'other'; no rawInput key at all.
  329. expect(updates[0]).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'Doing a thing', kind: 'other', status: 'in_progress' })
  330. // Title replaced; content falls back to the raw result content.
  331. expect(updates[1]).toEqual({
  332. sessionUpdate: 'tool_call_update',
  333. toolCallId: 'c1',
  334. status: 'completed',
  335. content: [{ type: 'content', content: { type: 'text', text: 'kept' } }],
  336. title: 'Did the thing',
  337. })
  338. })
  339. it('holds ONLY in-flight calls: the callId entry is removed once its result is presented', () => {
  340. const presenter = new ToolPresenter(registryOf(bashLike))
  341. updatesWith(
  342. presenter,
  343. evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }),
  344. evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'o' }], isError: false }),
  345. )
  346. // A SECOND result for the same callId now finds nothing remembered, so it
  347. // falls back to raw content (proving the first result consumed the entry —
  348. // the map does not retain finished calls).
  349. const [late] = updatesWith(presenter, evt('tool/result', {
  350. turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'late' }], isError: false,
  351. }))
  352. expect(late).toMatchObject({ content: [{ type: 'content', content: { type: 'text', text: 'late' } }] })
  353. })
  354. it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => {
  355. // A buggy tool whose display callbacks throw must not fail a live turn or a session/load
  356. // replay (docs/defensive-patterns.md "contain callback exceptions at the boundary"). The
  357. // presenter reports the error and falls back to generic rendering.
  358. const boom: ToolDefinition = {
  359. name: 'boom',
  360. description: 'b',
  361. parameters: {},
  362. output: UNUSED_TOOL_OUTPUT,
  363. execute: async () => [],
  364. presentCall: () => { throw new Error('call boom') },
  365. presentResult: () => { throw new Error('result boom') },
  366. }
  367. const errors: string[] = []
  368. const presenter = new ToolPresenter(registryOf(boom), msg => errors.push(msg))
  369. const updates = updatesWith(
  370. presenter,
  371. evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{"a":1}' }),
  372. evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }),
  373. )
  374. // tool/call fell back to title=name, raw args as rawInput.
  375. expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom', kind: 'other', rawInput: { a: 1 } })
  376. // tool/result fell back to the raw content.
  377. expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
  378. // Both throws were reported, not propagated.
  379. expect(errors).toHaveLength(2)
  380. expect(errors[0]).toContain('presentCall threw')
  381. expect(errors[1]).toContain('presentResult threw')
  382. })
  383. it('contains a throwing presenter even with the DEFAULT (no-op) onError sink', () => {
  384. // Constructed without an onError sink (the default `() => {}`): a throwing
  385. // presenter is still swallowed and falls back generically — the absence of a
  386. // logger must not turn a display bug into a propagated exception.
  387. const boom: ToolDefinition = {
  388. name: 'boom',
  389. description: 'b',
  390. parameters: {},
  391. output: UNUSED_TOOL_OUTPUT,
  392. execute: async () => [],
  393. presentCall: () => { throw new Error('call boom') },
  394. presentResult: () => { throw new Error('result boom') },
  395. }
  396. const presenter = new ToolPresenter(registryOf(boom))
  397. const updates = updatesWith(
  398. presenter,
  399. evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{}' }),
  400. evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }),
  401. )
  402. expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom' })
  403. expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
  404. })
  405. it('an unknown render-intent card throws via the exhaustiveness guard (closed union)', () => {
  406. // The bridge switches on `view.card` and ends with assertNever: a rogue card
  407. // (only reachable by a cast — the union is closed) must throw, so adding a
  408. // real variant later fails to compile at the switch instead of silently
  409. // dropping the card.
  410. const rogue: ToolDefinition = {
  411. name: 'rogue',
  412. description: 'r',
  413. parameters: {},
  414. output: UNUSED_TOOL_OUTPUT,
  415. execute: async () => [],
  416. // A card value outside the union — forced with a cast (no valid input reaches this).
  417. presentCall: () => ({ card: 'chart', title: 'nope' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
  418. }
  419. const presenter = new ToolPresenter(registryOf(rogue))
  420. expect(() => updatesWith(presenter, evt('tool/call', {
  421. turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}',
  422. }))).toThrow('unreachable variant')
  423. })
  424. it('an unknown render-intent RESULT card throws via the exhaustiveness guard (closed union)', () => {
  425. // The result-side renderer is also an exhaustive switch + assertNever: a rogue
  426. // result card (only reachable by a cast) must throw, so adding a real result
  427. // variant later fails to compile at the switch.
  428. const rogue: ToolDefinition = {
  429. name: 'rogue',
  430. description: 'r',
  431. parameters: {},
  432. output: UNUSED_TOOL_OUTPUT,
  433. execute: async () => [],
  434. presentCall: () => ({ card: 'generic', title: 'r' }),
  435. presentResult: () => ({ card: 'chart' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentResult']>>,
  436. }
  437. const presenter = new ToolPresenter(registryOf(rogue))
  438. expect(() => updatesWith(
  439. presenter,
  440. evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}' }),
  441. evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }),
  442. )).toThrow('unreachable variant')
  443. })
  444. it('forwards fs-tool render intents onto the wire (REAL read → generic locations, edit → diff content)', async () => {
  445. // Use the SHIPPING fs tools (not a stand-in), booted through their real
  446. // plugins, so the wire tool_call carries the actual presentCall output —
  447. // read's follow-along `locations` and edit's `diff` content block. (docs/testing.md
  448. // "prefer the real implementation over a mock".)
  449. const ctx = new Context()
  450. await ctx.plugin(SystemPrompt)
  451. await ctx.plugin(ToolRegistry)
  452. await ctx.plugin(FsLocal)
  453. await ctx.plugin(ToolFs)
  454. const presenter = new ToolPresenter(ctx.tools)
  455. const [readCall] = updatesWith(presenter, evt('tool/call', {
  456. turn: 1, step: 1, callId: CallId('r1'), name: 'read',
  457. arguments: JSON.stringify({ file_path: 'src/a.ts', offset: 12 }),
  458. }))
  459. // A generic card: the read window is in the title, the offset drives the
  460. // follow-along location line. No rawInput (the window lives in the title).
  461. expect(readCall).toMatchObject({
  462. sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'Read src/a.ts (from line 12)', kind: 'read',
  463. locations: [{ path: 'src/a.ts', line: 12 }],
  464. })
  465. expect((readCall as { rawInput?: unknown }).rawInput).toBeUndefined()
  466. const [editCall] = updatesWith(presenter, evt('tool/call', {
  467. turn: 1, step: 1, callId: CallId('e1'), name: 'edit',
  468. arguments: JSON.stringify({ file_path: 'src/b.ts', old_string: 'x', new_string: 'y' }),
  469. }))
  470. // A diff card: `edit` kind, a `{ type: 'diff' }` content block carrying the
  471. // literal old→new replacement, plus the follow-along location.
  472. expect(editCall).toMatchObject({
  473. sessionUpdate: 'tool_call', toolCallId: 'e1', title: 'Edit src/b.ts', kind: 'edit',
  474. locations: [{ path: 'src/b.ts' }],
  475. content: [{ type: 'diff', path: 'src/b.ts', oldText: 'x', newText: 'y' }],
  476. })
  477. await ctx.fiber.dispose()
  478. })
  479. })
  480. describe('terminal-card mapping (capability-gated)', () => {
  481. // A tool that renders as a terminal — a stand-in for tool-bash's shape, letting
  482. // us drive the bridge's terminal mapping without the real executor. `callCard`
  483. // selects a terminal call view (optionally with a cwd) or a generic one (for the
  484. // orphan-guard test); `resultTerminal` is the terminal result view's output/exit.
  485. type CallCard = { card: 'terminal'; cwd?: string } | { card: 'generic' }
  486. type ResultTerm = { title?: string; output?: string; exitCode?: number; signal?: string }
  487. const termTool = (callCard: CallCard, resultTerminal: ResultTerm): ToolDefinition => ({
  488. name: 'bash',
  489. description: 'run a command',
  490. parameters: {},
  491. output: UNUSED_TOOL_OUTPUT,
  492. execute: async () => [],
  493. presentCall: (args: unknown) => {
  494. const command = (args as { command: string }).command
  495. const description = (args as { description: string }).description
  496. if (callCard.card === 'terminal') {
  497. return { card: 'terminal', title: command, description, ...callCard.cwd !== undefined ? { cwd: callCard.cwd } : {} }
  498. }
  499. return { card: 'generic', title: command, kind: 'execute', rawInput: command, content: [{ type: 'text', text: description }] }
  500. },
  501. presentResult: () => ({ card: 'terminal', ...resultTerminal }),
  502. })
  503. const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) })
  504. const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false })
  505. const prunedResultEvent = {
  506. ...resultEvent,
  507. seq: 2,
  508. data: {
  509. ...resultEvent.data,
  510. content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
  511. },
  512. surfaceOp: { op: 'replace', start: 1, end: 1 },
  513. sourceEventSeqs: [1],
  514. } as SessionEvent
  515. function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
  516. const presenter = new ToolPresenter(registryOf(tool))
  517. const out: SessionNotification['update'][] = []
  518. for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, { enabled, cwd })
  519. return out
  520. }
  521. it('capability ON: description content THEN terminal block; cwd from the session header when the tool gives none', () => {
  522. const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent)
  523. expect(call).toMatchObject({
  524. sessionUpdate: 'tool_call',
  525. content: [
  526. { type: 'content', content: { type: 'text', text: 'Greet' } },
  527. { type: 'terminal', terminalId: 'c1' },
  528. ],
  529. _meta: { terminal_info: { terminal_id: 'c1', cwd: '/work/proj' } },
  530. })
  531. // The update OMITS content (it would clobber the terminal block) and carries output + exit.
  532. expect(update).toEqual({
  533. sessionUpdate: 'tool_call_update',
  534. toolCallId: 'c1',
  535. status: 'completed',
  536. _meta: { terminal_output: { terminal_id: 'c1', data: 'hi\n' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } },
  537. })
  538. })
  539. it('live/replay translation preserves the original terminal completion across a pruning rewrite', () => {
  540. const updates = termUpdates(
  541. termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }),
  542. true,
  543. '/work/proj',
  544. callEvent,
  545. resultEvent,
  546. prunedResultEvent,
  547. )
  548. expect(updates).toHaveLength(2)
  549. expect(updates[1]).toEqual({
  550. sessionUpdate: 'tool_call_update',
  551. toolCallId: 'c1',
  552. status: 'completed',
  553. _meta: {
  554. terminal_output: { terminal_id: 'c1', data: 'hi\n' },
  555. terminal_exit: { terminal_id: 'c1', exit_code: 0 },
  556. },
  557. })
  558. })
  559. it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
  560. const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
  561. expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
  562. const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: nativePath('sub', 'dir') }, { output: 'x' }), true, nativeAbsolute('/work/proj'), callEvent)
  563. // Relative workdir resolved against the session cwd — the card header matches
  564. // where execution actually ran (tool-bash resolves the same way).
  565. expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe(nativeAbsolute('/work/proj', 'sub', 'dir'))
  566. // No session cwd to resolve against → the relative tool cwd is passed through as-is.
  567. const [noSessionCwd] = termUpdates(termTool({ card: 'terminal', cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent)
  568. expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only')
  569. })
  570. it('capability ON: a signal kill maps to terminal_exit.signal', () => {
  571. const [, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent)
  572. expect((update as unknown as { _meta: { terminal_exit: unknown } })._meta.terminal_exit).toEqual({ terminal_id: 'c1', signal: 'SIGKILL' })
  573. })
  574. it('capability ON: a terminal result with output but NO exit/signal emits terminal_output and NO exit pill', () => {
  575. // A terminal-rendering tool that reports no structured exit (neither exitCode
  576. // nor signal) — the card shows output but no exit pill.
  577. const [, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'partial' }), true, '/w', callEvent, resultEvent)
  578. const meta = (update as unknown as { _meta: { terminal_output?: unknown; terminal_exit?: unknown } })._meta
  579. expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'partial' })
  580. expect(meta.terminal_exit).toBeUndefined()
  581. })
  582. it('capability OFF: no terminal block or _meta; the description content and the bridge-derived fenced result render', () => {
  583. const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent)
  584. expect(call).toEqual({
  585. sessionUpdate: 'tool_call',
  586. toolCallId: 'c1',
  587. title: 'echo hi',
  588. kind: 'execute',
  589. status: 'in_progress',
  590. rawInput: 'echo hi',
  591. content: [{ type: 'content', content: { type: 'text', text: 'Greet' } }],
  592. })
  593. // The bridge derives the fenced ```console fallback from the terminal output.
  594. expect(update).toEqual({
  595. sessionUpdate: 'tool_call_update',
  596. toolCallId: 'c1',
  597. status: 'completed',
  598. content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }],
  599. })
  600. })
  601. it('orphan guard: a result-side terminal with a GENERIC call is dropped (no orphan terminal_output)', () => {
  602. // presentCall is a generic card, but presentResult returns a terminal view —
  603. // the bridge must not emit _meta.terminal_output for a terminal Zed never made.
  604. const [call, update] = termUpdates(termTool({ card: 'generic' }, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent)
  605. // The call was generic → ordinary tool_call (description content, no _meta).
  606. expect((call as { _meta?: unknown })._meta).toBeUndefined()
  607. expect((call as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'Greet' } }])
  608. // The result falls back to the RAW result content (the tool/result event's text); NO terminal _meta.
  609. expect((update as { _meta?: unknown })._meta).toBeUndefined()
  610. expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'hi\n' } }])
  611. })
  612. it('capability ON: a terminal result title replaces the completed-card title; missing output emits empty data', () => {
  613. // A terminal result MAY carry a replacement title and MAY omit output (a run
  614. // that produced nothing) — the _meta carries empty data, not a dropped key.
  615. const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', exitCode: 0 }), true, '/w', callEvent, resultEvent)
  616. expect(update).toEqual({
  617. sessionUpdate: 'tool_call_update',
  618. toolCallId: 'c1',
  619. status: 'completed',
  620. title: 'Ran echo',
  621. _meta: { terminal_output: { terminal_id: 'c1', data: '' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } },
  622. })
  623. })
  624. it('capability OFF: a terminal result title rides on the fenced fallback update', () => {
  625. const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', output: 'hi\n' }), false, '/w', callEvent, resultEvent)
  626. expect(update).toEqual({
  627. sessionUpdate: 'tool_call_update',
  628. toolCallId: 'c1',
  629. status: 'completed',
  630. content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }],
  631. title: 'Ran echo',
  632. })
  633. })
  634. it('a terminal call with NO description and NO capability is a bare execute card (no content key)', () => {
  635. // A terminal view whose presentCall omits `description`, with the capability
  636. // OFF: no description block and no terminal block → the card carries no content.
  637. const noDesc: ToolDefinition = {
  638. name: 'bash',
  639. description: 'run a command',
  640. parameters: {},
  641. output: UNUSED_TOOL_OUTPUT,
  642. execute: async () => [],
  643. presentCall: (args: unknown) => ({ card: 'terminal', title: (args as { command: string }).command }),
  644. }
  645. const [call] = termUpdates(noDesc, false, undefined, callEvent)
  646. expect(call).toEqual({
  647. sessionUpdate: 'tool_call',
  648. toolCallId: 'c1',
  649. title: 'echo hi',
  650. kind: 'execute',
  651. status: 'in_progress',
  652. rawInput: 'echo hi',
  653. })
  654. })
  655. })
  656. describe('diff-card mapping', () => {
  657. // A stand-in diff tool, letting us drive the bridge's diff arm across shapes
  658. // the shipping fs tools don't emit (no locations, empty diffs).
  659. const diffTool = (view: unknown): ToolDefinition => ({
  660. name: 'writer',
  661. description: 'writes a file',
  662. parameters: {},
  663. output: UNUSED_TOOL_OUTPUT,
  664. execute: async () => [],
  665. presentCall: () => view as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
  666. })
  667. function callUpdate(tool: ToolDefinition, cwd: string | undefined): SessionNotification['update'] {
  668. const presenter = new ToolPresenter(registryOf(tool))
  669. const out: SessionNotification['update'][] = []
  670. streamSessionEventUpdate(
  671. SessionId('s1'),
  672. evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'writer', arguments: '{}' }),
  673. n => out.push(n.update),
  674. presenter,
  675. { enabled: false, cwd },
  676. )
  677. return out[0]!
  678. }
  679. it('a diff with NO locations relativizes the title off the first diff path; omits the locations key', () => {
  680. const update = callUpdate(diffTool({ card: 'diff', title: 'Write /work/proj/a.txt', diffs: [{ path: '/work/proj/a.txt', oldText: null, newText: 'x' }] }), '/work/proj')
  681. expect(update).toEqual({
  682. sessionUpdate: 'tool_call',
  683. toolCallId: 'c1',
  684. title: 'Write a.txt',
  685. kind: 'edit',
  686. status: 'in_progress',
  687. content: [{ type: 'diff', path: '/work/proj/a.txt', oldText: null, newText: 'x' }],
  688. })
  689. })
  690. it('a diff with an EMPTY diffs array omits the content key (no diff blocks to send)', () => {
  691. const update = callUpdate(diffTool({ card: 'diff', title: 'Write nothing', diffs: [] }), undefined)
  692. expect(update).toEqual({
  693. sessionUpdate: 'tool_call',
  694. toolCallId: 'c1',
  695. title: 'Write nothing',
  696. kind: 'edit',
  697. status: 'in_progress',
  698. })
  699. })
  700. })
  701. describe('result-time diff card (REAL fs edit tool → tool_call_update diff blocks)', () => {
  702. // Drive the SHIPPING fs edit tool through the bridge: the pending tool/call installs the
  703. // call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`,
  704. // which presentResult narrows into a `diff` result card the bridge forwards as `{ type:
  705. // 'diff' }` content blocks. The real tool is required because its result metadata is the contract.
  706. it('live/replay translation keeps the applied diff when a pruning rewrite follows', async () => {
  707. const ctx = await fsCtx()
  708. const presenter = new ToolPresenter(ctx.tools)
  709. const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' })
  710. // The applied hunk the tool would compute and persist on the result meta.
  711. const meta = { diffs: [{ path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
  712. const originalResult = evt('tool/result', {
  713. turn: 1,
  714. step: 1,
  715. callId: CallId('e1'),
  716. content: [{ type: 'text', text: 'ok' }],
  717. isError: false,
  718. meta,
  719. })
  720. const replacement = {
  721. ...originalResult,
  722. seq: 3,
  723. data: {
  724. ...originalResult.data,
  725. content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
  726. },
  727. surfaceOp: { op: 'replace', start: 2, end: 2 },
  728. sourceEventSeqs: [2],
  729. } as SessionEvent
  730. const updates = updatesWith(
  731. presenter,
  732. evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
  733. originalResult,
  734. replacement,
  735. )
  736. expect(updates).toHaveLength(2)
  737. const resultUpdate = updates[1]
  738. expect(resultUpdate).toEqual({
  739. sessionUpdate: 'tool_call_update',
  740. toolCallId: 'e1',
  741. status: 'completed',
  742. title: 'Edit src/b.ts',
  743. content: [{ type: 'diff', path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
  744. })
  745. await ctx.fiber.dispose()
  746. })
  747. it('an error result carries NO diff card (falls back to raw content)', async () => {
  748. const ctx = await fsCtx()
  749. const presenter = new ToolPresenter(ctx.tools)
  750. const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' })
  751. const [, resultUpdate] = updatesWith(
  752. presenter,
  753. evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
  754. evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'Error: boom' }], isError: true }),
  755. )
  756. expect(resultUpdate).toMatchObject({ sessionUpdate: 'tool_call_update', status: 'failed' })
  757. expect(resultUpdate).not.toHaveProperty('content', expect.arrayContaining([expect.objectContaining({ type: 'diff' })]))
  758. await ctx.fiber.dispose()
  759. })
  760. it('the completed diff TITLE relativizes against the session cwd (the result title replaces the card header)', async () => {
  761. // A `tool_call_update.title` replaces the card header, so the result-side diff must
  762. // relativize its title exactly as the pending card did — otherwise a completed
  763. // absolute-path edit flips `Edit src/b.ts` back to the raw absolute path. Diff and location
  764. // paths remain absolute so the editor can open the real file.
  765. const ctx = await fsCtx()
  766. const presenter = new ToolPresenter(ctx.tools)
  767. const workspace = nativeAbsolute('/work/proj')
  768. const file = nativeAbsolute('/work/proj', 'src', 'b.ts')
  769. const args = JSON.stringify({ file_path: file, old_string: 'OLD', new_string: 'NEW' })
  770. const meta = { diffs: [{ path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
  771. const out: SessionNotification['update'][] = []
  772. const rendering = { enabled: false, cwd: workspace }
  773. for (const event of [
  774. evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
  775. evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
  776. ]) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, rendering)
  777. expect(out[1]).toEqual({
  778. sessionUpdate: 'tool_call_update',
  779. toolCallId: 'e1',
  780. status: 'completed',
  781. title: `Edit ${nativePath('src', 'b.ts')}`,
  782. content: [{ type: 'diff', path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
  783. })
  784. await ctx.fiber.dispose()
  785. })
  786. it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => {
  787. // Shipping edit always has a hunk and write falls back to a whole-file diff, so a synthetic
  788. // tool is required to cover both absent-title and empty-content result branches.
  789. const emptyDiffTool: ToolDefinition = {
  790. name: 'writer',
  791. description: 'writes a file',
  792. parameters: {},
  793. output: UNUSED_TOOL_OUTPUT,
  794. execute: async () => [],
  795. presentCall: () => ({ card: 'diff', title: 'Write x', diffs: [{ path: 'x', oldText: null, newText: 'y' }] }),
  796. presentResult: () => ({ card: 'diff', diffs: [] }),
  797. }
  798. const presenter = new ToolPresenter(registryOf(emptyDiffTool))
  799. const [, resultUpdate] = updatesWith(
  800. presenter,
  801. evt('tool/call', { turn: 1, step: 1, callId: CallId('w1'), name: 'writer', arguments: '{}' }),
  802. evt('tool/result', { turn: 1, step: 1, callId: CallId('w1'), content: [{ type: 'text', text: 'ok' }], isError: false }),
  803. )
  804. expect(resultUpdate).toEqual({
  805. sessionUpdate: 'tool_call_update',
  806. toolCallId: 'w1',
  807. status: 'completed',
  808. })
  809. expect(resultUpdate).not.toHaveProperty('content')
  810. expect(resultUpdate).not.toHaveProperty('title')
  811. })
  812. })
  813. describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => {
  814. // The bridge relativizes a file card's TITLE against the session workspace cwd (mirroring the
  815. // reference adapter's `toDisplayPath`), while leaving location/diff paths raw. Use real fs tools
  816. // and the absolute paths an editor supplies; presentation itself is args-only and lacks cwd.
  817. function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] {
  818. const presenter = new ToolPresenter(ctx.tools)
  819. const out: SessionNotification['update'][] = []
  820. streamSessionEventUpdate(
  821. SessionId('s1'),
  822. evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name, arguments: JSON.stringify(args) }),
  823. n => out.push(n.update),
  824. presenter,
  825. { enabled: false, cwd: sessionCwd },
  826. )
  827. return out[0]!
  828. }
  829. it('read: an absolute path inside the workspace relativizes the TITLE; the location path stays absolute', async () => {
  830. const ctx = await fsCtx()
  831. const workspace = nativeAbsolute('/work/proj')
  832. const file = nativeAbsolute('/work/proj', 'src', 'a.ts')
  833. const update = callUpdate(ctx, workspace, 'read', { file_path: file, offset: 5 })
  834. expect(update).toMatchObject({
  835. title: `Read ${nativePath('src', 'a.ts')} (from line 5)`,
  836. locations: [{ path: file, line: 5 }],
  837. })
  838. await ctx.fiber.dispose()
  839. })
  840. it('edit: the diff TITLE relativizes; the diff/location paths stay absolute (the editor opens the real path)', async () => {
  841. const ctx = await fsCtx()
  842. const workspace = nativeAbsolute('/work/proj')
  843. const file = nativeAbsolute('/work/proj', 'src', 'b.ts')
  844. const update = callUpdate(ctx, workspace, 'edit', { file_path: file, old_string: 'x', new_string: 'y' })
  845. expect(update).toMatchObject({
  846. title: `Edit ${nativePath('src', 'b.ts')}`,
  847. locations: [{ path: file }],
  848. content: [{ type: 'diff', path: file, oldText: 'x', newText: 'y' }],
  849. })
  850. await ctx.fiber.dispose()
  851. })
  852. it('a path OUTSIDE the workspace is left as-is (no `..` title)', async () => {
  853. const ctx = await fsCtx()
  854. const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/etc/passwd' })
  855. expect((update as { title: string }).title).toBe('Read /etc/passwd')
  856. await ctx.fiber.dispose()
  857. })
  858. it('an in-workspace file whose relative form starts with `..` chars (a sibling name) still relativizes', async () => {
  859. // `/work/proj/..cache/x` is inside the workspace — its relative form `..cache/x` begins
  860. // with the chars `..` but is not a parent segment. Segment-aware guarding must relativize it,
  861. // matching targets under `cwd + sep` in the reference adapter.
  862. const ctx = await fsCtx()
  863. const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativeAbsolute('/work/proj', '..cache', 'x.ts') })
  864. expect((update as { title: string }).title).toBe(`Read ${nativePath('..cache', 'x.ts')}`)
  865. await ctx.fiber.dispose()
  866. })
  867. it('no session cwd → the absolute title is left unchanged', async () => {
  868. const ctx = await fsCtx()
  869. const update = callUpdate(ctx, undefined, 'read', { file_path: '/work/proj/src/a.ts' })
  870. expect((update as { title: string }).title).toBe('Read /work/proj/src/a.ts')
  871. await ctx.fiber.dispose()
  872. })
  873. it('a relative path is passed through unchanged (already display-friendly)', async () => {
  874. const ctx = await fsCtx()
  875. const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativePath('src', 'a.ts') })
  876. expect((update as { title: string }).title).toBe(`Read ${nativePath('src', 'a.ts')}`)
  877. await ctx.fiber.dispose()
  878. })
  879. })
  880. describe('agentOptions', () => {
  881. it('includes only the fields present in config', () => {
  882. expect(agentOptions({})).toEqual({})
  883. expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' })
  884. expect(agentOptions({ provider: 'p', model: 'm' })).toEqual({ provider: 'p', model: 'm' })
  885. })
  886. })