session-edit.host.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. import { Context } from '@deepseek-ai/cordis'
  2. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  3. import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
  4. import LlmRuntime, { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  5. import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
  6. import type { Session, SessionEvent, SurfaceIntent } from '@deepseek-ai/dsh-session'
  7. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  8. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  9. import ToolRuntime from '@deepseek-ai/dsh-tools'
  10. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  11. import { describe, expect, it, vi } from 'vitest'
  12. import { ApiSessionAgentController } from '../src/agent.ts'
  13. import { SessionCommandController } from '../src/commands.ts'
  14. import type { SessionEditRequest, SessionRequestId } from '../src/types.ts'
  15. import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts'
  16. import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  17. function appendTurn(session: Session, turn: number, prompt: string, answer: string): SessionEvent<'user/message'> {
  18. session.append('turn/start', { turn })
  19. session.append('step/start', { turn, step: 1 })
  20. const user = session.append('user/message', createUserMessage({
  21. content: [{ type: 'text', text: prompt }],
  22. source: { kind: 'user' },
  23. }), { surfaceOp: 'append' })
  24. session.append('assistant/message', {
  25. turn,
  26. step: 1,
  27. message: createAssistantMessage({
  28. content: [{ type: 'text', text: answer }],
  29. source: { provider: 'fixture', model: 'fixture-model' },
  30. }),
  31. }, { surfaceOp: 'append', sourceEventSeqs: [] })
  32. session.append('step/end', { turn, step: 1 })
  33. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  34. return user
  35. }
  36. async function harness(
  37. running = false,
  38. inputModalities: readonly ('text' | 'image')[] | null = ['text', 'image'],
  39. providers: readonly string[] = ['fixture'],
  40. ): Promise<{
  41. ctx: Context
  42. controller: SessionCommandController
  43. agent: Agent
  44. session: Session
  45. sent: Array<{
  46. message: Parameters<Agent['send']>[0]
  47. followingMessages: readonly Parameters<Agent['send']>[0][]
  48. intent: SurfaceIntent | undefined
  49. position: string | undefined
  50. }>
  51. cancel: ReturnType<typeof vi.fn>
  52. }> {
  53. const ctx = new Context()
  54. await ctx.plugin(SessionStore)
  55. await ctx.plugin(AgentRegistry)
  56. installSessionReadTestServices(ctx)
  57. ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
  58. ctx.provide('llm', {
  59. listProviders: () => providers.map(id => ({ id })),
  60. resolveModelInfo: () => Promise.resolve(inputModalities === null ? {} : { inputModalities }),
  61. } as never)
  62. ctx.provide('agentDefaultModel', {
  63. currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
  64. saveSelection: () => Promise.resolve(),
  65. } as never)
  66. const session = ctx.sessions.create(SessionId('edit-session'), { meta: { cwd: '/workspace' } })
  67. const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
  68. let status: Agent['status'] = running ? 'running' : 'idle'
  69. let nextTurn = 100
  70. const sent: Array<{
  71. message: Parameters<Agent['send']>[0]
  72. followingMessages: readonly Parameters<Agent['send']>[0][]
  73. intent: SurfaceIntent | undefined
  74. position: string | undefined
  75. }> = []
  76. const agent = {
  77. id: session.id,
  78. options: { provider: 'fixture', model: 'fixture-model' },
  79. session,
  80. inbox,
  81. get status() { return status },
  82. ctx,
  83. send(message, _target, _wakeup, options) {
  84. sent.push({
  85. message,
  86. followingMessages: options?.followingMessages ?? [],
  87. intent: options?.surfaceIntent,
  88. position: options?.position,
  89. })
  90. const turn = nextTurn
  91. nextTurn += 1
  92. session.append('turn/start', { turn })
  93. session.append('step/start', { turn, step: 1 })
  94. session.append('user/message', message, options?.surfaceIntent ?? { surfaceOp: 'append' })
  95. for (const following of options?.followingMessages ?? []) {
  96. session.append('user/message', following, { surfaceOp: 'append' })
  97. }
  98. session.append('step/end', { turn, step: 1 })
  99. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  100. },
  101. followup: vi.fn(),
  102. steer: vi.fn(),
  103. inject: vi.fn(),
  104. cancel: vi.fn(() => {
  105. status = 'idle'
  106. ctx.emit('agent/status', { agent, status: 'idle' })
  107. }),
  108. runMaintenance: async operation => operation(new AbortController().signal),
  109. whenIdle: () => Promise.resolve(),
  110. } satisfies Agent
  111. const cancel = vi.mocked(agent.cancel)
  112. ctx.agents.register(agent)
  113. const selection: ModelSelectionRef = {
  114. current: { provider: 'fixture', model: 'fixture-model' },
  115. assembled: undefined,
  116. }
  117. const agents = {
  118. resolveAgent: () => Promise.resolve({ agent }),
  119. selectionFor: () => selection,
  120. serializeImageAdmission: (_agent: Agent, operation: () => Promise<unknown>) => operation(),
  121. } as unknown as ApiSessionAgentController
  122. return {
  123. ctx,
  124. controller: new SessionCommandController(ctx, agents, '/workspace'),
  125. agent,
  126. session,
  127. sent,
  128. cancel,
  129. }
  130. }
  131. function request(
  132. messageSeq: number,
  133. expectedLastUserSeq: number,
  134. text = 'edited',
  135. clientTimeZone: string | undefined = 'UTC',
  136. ): SessionEditRequest {
  137. return {
  138. requestId: 'edit-request' as SessionRequestId,
  139. sessionId: SessionId('edit-session'),
  140. messageSeq,
  141. expectedLastUserSeq,
  142. text,
  143. ...(clientTimeZone === undefined ? {} : { clientTimeZone }),
  144. }
  145. }
  146. describe('session.edit', () => {
  147. it.each([false, true])('replaces the latest turn and prioritizes the rerun (running=%s)', async (running) => {
  148. const fixture = await harness(running)
  149. appendTurn(fixture.session, 1, 'first', 'first answer')
  150. const second = appendTurn(fixture.session, 2, 'second', 'second answer')
  151. fixture.agent.inbox.append('next-turn', createUserMessage({
  152. content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
  153. }))
  154. const result = await fixture.controller.edit(request(second.seq, second.seq), new AbortController().signal)
  155. expect(result).toMatchObject({ accepted: true })
  156. expect(fixture.sent).toHaveLength(1)
  157. expect(fixture.sent[0]).toMatchObject({
  158. message: { content: [{ type: 'text', text: 'edited' }] },
  159. position: 'front',
  160. intent: {
  161. surfaceOp: { op: 'replace', start: second.seq },
  162. conversationOp: { op: 'replace', start: second.seq - 2 },
  163. },
  164. })
  165. expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
  166. [{ type: 'text', text: 'first' }],
  167. [{ type: 'text', text: 'first answer' }],
  168. [{ type: 'text', text: 'edited' }],
  169. ])
  170. expect(fixture.cancel).toHaveBeenCalledTimes(running ? 1 : 0)
  171. if (running) expect(fixture.cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
  172. expect(fixture.agent.inbox.nextTurn.map(message => message.content)).toEqual([
  173. [{ type: 'text', text: 'queued' }],
  174. ])
  175. await fixture.ctx.fiber.dispose()
  176. })
  177. it('preserves non-text blocks and permits unchanged text', async () => {
  178. const fixture = await harness()
  179. fixture.session.append('turn/start', { turn: 1 })
  180. fixture.session.append('step/start', { turn: 1, step: 1 })
  181. const prompt = fixture.session.append('user/message', createUserMessage({
  182. content: [
  183. { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
  184. { type: 'text', text: 'same' },
  185. { type: 'text', text: ' second block' },
  186. ],
  187. source: { kind: 'user' },
  188. }), { surfaceOp: 'append' })
  189. fixture.session.append('user/message', createUserMessage({
  190. content: [{ type: 'text', text: 'referenced context' }],
  191. source: {
  192. kind: 'session-reference',
  193. form: 'recall',
  194. version: 1,
  195. references: [],
  196. } as never,
  197. }), { surfaceOp: 'append' })
  198. fixture.session.append('step/end', { turn: 1, step: 1 })
  199. fixture.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  200. await fixture.controller.edit(request(prompt.seq, prompt.seq, 'same second block'), new AbortController().signal)
  201. expect(fixture.sent[0]?.message.content).toEqual([
  202. { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
  203. { type: 'text', text: 'same second block' },
  204. ])
  205. expect(fixture.sent[0]?.followingMessages).toEqual([
  206. expect.objectContaining({ content: [{ type: 'text', text: 'referenced context' }] }),
  207. ])
  208. expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
  209. [
  210. { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
  211. { type: 'text', text: 'same second block' },
  212. ],
  213. [{ type: 'text', text: 'referenced context' }],
  214. ])
  215. await fixture.ctx.fiber.dispose()
  216. })
  217. it('rejects a preserved image before interrupting a text-only model', async () => {
  218. const fixture = await harness(true, ['text'])
  219. fixture.session.append('turn/start', { turn: 1 })
  220. fixture.session.append('step/start', { turn: 1, step: 1 })
  221. const prompt = fixture.session.append('user/message', createUserMessage({
  222. content: [
  223. { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
  224. { type: 'text', text: 'describe' },
  225. ],
  226. source: { kind: 'user' },
  227. }), { surfaceOp: 'append' })
  228. await expect(fixture.controller.edit(
  229. request(prompt.seq, prompt.seq, 'describe again'),
  230. new AbortController().signal,
  231. )).rejects.toMatchObject({ code: 'session/attachment-invalid' })
  232. expect(fixture.cancel).not.toHaveBeenCalled()
  233. expect(fixture.sent).toEqual([])
  234. await fixture.ctx.fiber.dispose()
  235. })
  236. it('accepts a preserved image when the current model declares no modality list', async () => {
  237. const fixture = await harness(false, null)
  238. fixture.session.append('turn/start', { turn: 1 })
  239. fixture.session.append('step/start', { turn: 1, step: 1 })
  240. const prompt = fixture.session.append('user/message', createUserMessage({
  241. content: [
  242. { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
  243. { type: 'text', text: 'describe' },
  244. ],
  245. source: { kind: 'user' },
  246. }), { surfaceOp: 'append' })
  247. await expect(fixture.controller.edit(
  248. request(prompt.seq, prompt.seq, 'describe again'),
  249. new AbortController().signal,
  250. )).resolves.toMatchObject({ accepted: true })
  251. await fixture.ctx.fiber.dispose()
  252. })
  253. it('rejects invalid timezone and unavailable-model requests before interruption', async () => {
  254. const invalidZone = await harness(true)
  255. const zoneTarget = appendTurn(invalidZone.session, 1, 'prompt', 'answer')
  256. await expect(invalidZone.controller.edit({
  257. ...request(zoneTarget.seq, zoneTarget.seq),
  258. clientTimeZone: 'not/a-zone',
  259. }, new AbortController().signal)).rejects.toMatchObject({ code: 'session/invalid-time-zone' })
  260. expect(invalidZone.cancel).not.toHaveBeenCalled()
  261. await invalidZone.ctx.fiber.dispose()
  262. const unavailable = await harness(true, ['text'], [])
  263. const modelTarget = appendTurn(unavailable.session, 1, 'prompt', 'answer')
  264. await expect(unavailable.controller.edit(
  265. request(modelTarget.seq, modelTarget.seq),
  266. new AbortController().signal,
  267. )).rejects.toMatchObject({ code: 'session/model-unavailable' })
  268. expect(unavailable.cancel).not.toHaveBeenCalled()
  269. await unavailable.ctx.fiber.dispose()
  270. })
  271. it('honors cancellation before resolving the target Session', async () => {
  272. const fixture = await harness()
  273. const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
  274. const controller = new AbortController()
  275. controller.abort(new Error('request stopped'))
  276. await expect(fixture.controller.edit(
  277. request(target.seq, target.seq),
  278. controller.signal,
  279. )).rejects.toThrow('request stopped')
  280. expect(fixture.sent).toEqual([])
  281. await fixture.ctx.fiber.dispose()
  282. })
  283. it('maps an admission race to stale and preserves an unexpected send failure as internal', async () => {
  284. const raced = await harness()
  285. const racedTarget = appendTurn(raced.session, 1, 'prompt', 'answer')
  286. vi.spyOn(raced.agent, 'runMaintenance').mockImplementation(async (operation) => {
  287. appendTurn(raced.session, 2, 'newer prompt', 'newer answer')
  288. return operation(new AbortController().signal)
  289. })
  290. await expect(raced.controller.edit(
  291. request(racedTarget.seq, racedTarget.seq),
  292. new AbortController().signal,
  293. )).rejects.toMatchObject({ code: 'session/edit-stale' })
  294. await raced.ctx.fiber.dispose()
  295. const failedSend = await harness()
  296. const sendTarget = appendTurn(failedSend.session, 1, 'prompt', 'answer')
  297. const failure = new Error('send failed')
  298. vi.spyOn(failedSend.agent, 'send').mockImplementation(() => { throw failure })
  299. await expect(failedSend.controller.edit(
  300. request(sendTarget.seq, sendTarget.seq),
  301. new AbortController().signal,
  302. )).rejects.toMatchObject({ code: 'gateway/internal', cause: failure })
  303. await failedSend.ctx.fiber.dispose()
  304. })
  305. it.each([new Error('cancel failed'), 'cancel failed'])('preserves a thrown cancellation (%s) as an internal failure', async (reason) => {
  306. const fixture = await harness(true)
  307. const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
  308. vi.spyOn(fixture.agent, 'cancel').mockImplementation(() => { throw reason })
  309. await expect(fixture.controller.edit(
  310. request(target.seq, target.seq),
  311. new AbortController().signal,
  312. )).rejects.toMatchObject({ code: 'gateway/internal' })
  313. await fixture.ctx.fiber.dispose()
  314. })
  315. it.each([false, true])('reports an occupied maintenance slot as busy (running=%s)', async (running) => {
  316. const fixture = await harness(running)
  317. const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
  318. vi.spyOn(fixture.agent, 'runMaintenance').mockImplementation(() => { throw new Error('maintenance busy') })
  319. await expect(fixture.controller.edit(
  320. request(target.seq, target.seq),
  321. new AbortController().signal,
  322. )).rejects.toMatchObject({
  323. code: 'session/agent-busy',
  324. details: { reason: 'Error: maintenance busy' },
  325. })
  326. await fixture.ctx.fiber.dispose()
  327. })
  328. it.each([new Error('request stopped'), 'request stopped'])('honors caller cancellation while waiting for idle (%s)', async (reason) => {
  329. const fixture = await harness(true)
  330. const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
  331. const cancel = vi.spyOn(fixture.agent, 'cancel').mockImplementation(() => {})
  332. const controller = new AbortController()
  333. const editing = fixture.controller.edit(request(target.seq, target.seq), controller.signal)
  334. await vi.waitFor(() => { expect(cancel).toHaveBeenCalledOnce() })
  335. fixture.ctx.emit('agent/status', { agent: {} as Agent, status: 'idle' })
  336. fixture.ctx.emit('agent/status', { agent: fixture.agent, status: 'running' })
  337. controller.abort(reason)
  338. await expect(editing).rejects.toThrow(reason instanceof Error ? reason.message : 'edit request aborted')
  339. await fixture.ctx.fiber.dispose()
  340. })
  341. it('rejects when the edit message is discarded or its claimed turn closes before commit', async () => {
  342. const discarded = await harness()
  343. const discardedTarget = appendTurn(discarded.session, 1, 'prompt', 'answer')
  344. vi.spyOn(discarded.agent, 'send').mockImplementation((message) => {
  345. discarded.ctx.emit('agent/inbox/claimed', { agent: {} as Agent, message, turn: 8 })
  346. discarded.ctx.emit('agent/inbox/claimed', {
  347. agent: discarded.agent,
  348. message: createUserMessage({ content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }),
  349. turn: 8,
  350. })
  351. discarded.ctx.emit('agent/inbox/discarded', { agent: {} as Agent, message })
  352. discarded.ctx.emit('agent/inbox/discarded', { agent: discarded.agent, message })
  353. })
  354. await expect(discarded.controller.edit(
  355. request(discardedTarget.seq, discardedTarget.seq),
  356. new AbortController().signal,
  357. )).rejects.toMatchObject({ code: 'session/edit-stale' })
  358. await discarded.ctx.fiber.dispose()
  359. const closed = await harness()
  360. const closedTarget = appendTurn(closed.session, 1, 'prompt', 'answer')
  361. const unrelatedSession = closed.ctx.sessions.create(SessionId('unrelated-edit-session'))
  362. vi.spyOn(closed.agent, 'send').mockImplementation((message) => {
  363. closed.ctx.emit('agent/inbox/claimed', { agent: closed.agent, message, turn: 9 })
  364. closed.ctx.emit('session/event', unrelatedSession, {
  365. type: 'turn/end', seq: SessionSeq(0), time: 0, data: { turn: 9, reason: { kind: 'blocked' } },
  366. })
  367. closed.session.append('turn/end', { turn: 9, reason: { kind: 'blocked' } })
  368. })
  369. await expect(closed.controller.edit(
  370. request(closedTarget.seq, closedTarget.seq),
  371. new AbortController().signal,
  372. )).rejects.toMatchObject({ code: 'session/edit-stale' })
  373. await closed.ctx.fiber.dispose()
  374. })
  375. it('rejects when the Agent is disposed before the replacement commits', async () => {
  376. const fixture = await harness()
  377. const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
  378. vi.spyOn(fixture.agent, 'send').mockImplementation(() => {
  379. fixture.ctx.emit('agent/disposed', { agent: {} as Agent })
  380. fixture.ctx.emit('agent/disposed', { agent: fixture.agent })
  381. })
  382. await expect(fixture.controller.edit(
  383. request(target.seq, target.seq),
  384. new AbortController().signal,
  385. )).rejects.toMatchObject({ code: 'session/edit-stale' })
  386. await fixture.ctx.fiber.dispose()
  387. })
  388. it('allows the current replacement message to be edited again', async () => {
  389. const fixture = await harness()
  390. const original = appendTurn(fixture.session, 1, 'original', 'answer')
  391. const first = await fixture.controller.edit(
  392. request(original.seq, original.seq, 'first edit'),
  393. new AbortController().signal,
  394. )
  395. const second = await fixture.controller.edit(
  396. request(first.messageSeq, first.messageSeq, 'second edit'),
  397. new AbortController().signal,
  398. )
  399. expect(second.messageSeq).toBeGreaterThan(first.messageSeq)
  400. expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
  401. [{ type: 'text', text: 'second edit' }],
  402. ])
  403. await fixture.ctx.fiber.dispose()
  404. })
  405. it('retains a later compaction checkpoint positioned before the edited turn', async () => {
  406. const fixture = await harness()
  407. const first = appendTurn(fixture.session, 1, 'first', 'first answer')
  408. const second = appendTurn(fixture.session, 2, 'second', 'second answer')
  409. const firstAnswer = fixture.session.snapshotEvents().find(event =>
  410. event.type === 'assistant/message' && event.data.turn === 1)
  411. if (firstAnswer?.type !== 'assistant/message') throw new Error('missing first answer')
  412. const checkpoint = fixture.session.append('user/message', createUserMessage({
  413. content: [{ type: 'text', text: 'summary' }],
  414. source: { kind: 'plugin', plugin: 'compact' },
  415. }), {
  416. surfaceOp: { op: 'replace', start: first.seq, end: firstAnswer.seq },
  417. sourceEventSeqs: [first.seq, firstAnswer.seq],
  418. })
  419. await fixture.controller.edit(request(second.seq, second.seq), new AbortController().signal)
  420. expect(fixture.sent[0]?.intent?.surfaceOp).toMatchObject({
  421. op: 'replace',
  422. start: second.seq,
  423. })
  424. expect(fixture.session.surface.nodes[0]).toBe(checkpoint.seq)
  425. expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
  426. [{ type: 'text', text: 'summary' }],
  427. [{ type: 'text', text: 'edited' }],
  428. ])
  429. await fixture.ctx.fiber.dispose()
  430. })
  431. it('accepts legacy turns whose opening user message precedes step/start', async () => {
  432. const fixture = await harness()
  433. fixture.session.append('turn/start', { turn: 1 })
  434. const prompt = fixture.session.append('user/message', createUserMessage({
  435. content: [{ type: 'text', text: 'legacy prompt' }], source: { kind: 'user' },
  436. }), { surfaceOp: 'append' })
  437. fixture.session.append('step/start', { turn: 1, step: 1 })
  438. fixture.session.append('step/end', { turn: 1, step: 1 })
  439. fixture.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  440. await expect(fixture.controller.edit(
  441. request(prompt.seq, prompt.seq, 'edited legacy prompt', undefined),
  442. new AbortController().signal,
  443. )).resolves.toMatchObject({ accepted: true })
  444. expect(fixture.sent[0]?.message.content).toEqual([{ type: 'text', text: 'edited legacy prompt' }])
  445. await fixture.ctx.fiber.dispose()
  446. })
  447. it('rejects stale, shadowed, steering, empty, and malformed requests before admission', async () => {
  448. const fixture = await harness()
  449. const first = appendTurn(fixture.session, 1, 'first', 'first answer')
  450. const second = appendTurn(fixture.session, 2, 'second', 'second answer')
  451. const expectCode = async (value: Promise<unknown>, code: string): Promise<void> => {
  452. await expect(value).rejects.toMatchObject({ code })
  453. }
  454. await expectCode(
  455. fixture.controller.edit(request(first.seq, first.seq), new AbortController().signal),
  456. 'session/edit-stale',
  457. )
  458. await expectCode(
  459. fixture.controller.edit(request(first.seq, second.seq), new AbortController().signal),
  460. 'session/edit-unavailable',
  461. )
  462. await expectCode(
  463. fixture.controller.edit(request(-1, second.seq), new AbortController().signal),
  464. 'gateway/bad-request',
  465. )
  466. await expectCode(
  467. fixture.controller.edit(request(first.seq, -1), new AbortController().signal),
  468. 'gateway/bad-request',
  469. )
  470. await expectCode(
  471. fixture.controller.edit(request(second.seq, second.seq, ''), new AbortController().signal),
  472. 'gateway/bad-request',
  473. )
  474. const rewritten = fixture.session.append('user/message', createUserMessage({
  475. content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' },
  476. }), {
  477. surfaceOp: { op: 'replace', start: first.seq, end: SessionSeq(second.seq + 1) },
  478. sourceEventSeqs: [
  479. first.seq,
  480. SessionSeq(first.seq + 1),
  481. second.seq,
  482. SessionSeq(second.seq + 1),
  483. ],
  484. conversationOp: {
  485. op: 'replace',
  486. start: SessionSeq(first.seq - 2),
  487. end: SessionSeq(fixture.session.seq - 1),
  488. },
  489. })
  490. await expectCode(
  491. fixture.controller.edit(request(first.seq, rewritten.seq), new AbortController().signal),
  492. 'session/edit-unavailable',
  493. )
  494. await fixture.ctx.fiber.dispose()
  495. })
  496. it('rejects the latest human message after compaction removes it from the current surface', async () => {
  497. const fixture = await harness()
  498. const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
  499. const answer = fixture.session.snapshotEvents().find(event =>
  500. event.type === 'assistant/message' && event.data.turn === 1)
  501. if (answer?.type !== 'assistant/message') throw new Error('missing answer')
  502. fixture.session.append('user/message', createUserMessage({
  503. content: [{ type: 'text', text: 'summary' }],
  504. source: { kind: 'plugin', plugin: 'compact' },
  505. }), {
  506. surfaceOp: { op: 'replace', start: target.seq, end: answer.seq },
  507. sourceEventSeqs: [target.seq, answer.seq],
  508. })
  509. await expect(fixture.controller.edit(
  510. request(target.seq, target.seq),
  511. new AbortController().signal,
  512. )).rejects.toMatchObject({ code: 'session/edit-unavailable' })
  513. await fixture.ctx.fiber.dispose()
  514. })
  515. it('rejects non-human, textless, turnless, steered, and step-less targets', async () => {
  516. const expectCode = async (value: Promise<unknown>): Promise<void> => {
  517. await expect(value).rejects.toMatchObject({ code: 'session/edit-unavailable' })
  518. }
  519. const nonHuman = await harness()
  520. nonHuman.session.append('turn/start', { turn: 1 })
  521. nonHuman.session.append('step/start', { turn: 1, step: 1 })
  522. const context = nonHuman.session.append('user/message', createUserMessage({
  523. content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' },
  524. }), { surfaceOp: 'append' })
  525. const human = nonHuman.session.append('user/message', createUserMessage({
  526. content: [{ type: 'text', text: 'human' }], source: { kind: 'user' },
  527. }), { surfaceOp: 'append' })
  528. await expectCode(nonHuman.controller.edit(request(context.seq, human.seq), new AbortController().signal))
  529. await nonHuman.ctx.fiber.dispose()
  530. const textless = await harness()
  531. textless.session.append('turn/start', { turn: 1 })
  532. textless.session.append('step/start', { turn: 1, step: 1 })
  533. const image = textless.session.append('user/message', createUserMessage({
  534. content: [{ type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }],
  535. source: { kind: 'user' },
  536. }), { surfaceOp: 'append' })
  537. await expectCode(textless.controller.edit(request(image.seq, image.seq), new AbortController().signal))
  538. await textless.ctx.fiber.dispose()
  539. const turnless = await harness()
  540. const lone = turnless.session.append('user/message', createUserMessage({
  541. content: [{ type: 'text', text: 'lone' }], source: { kind: 'user' },
  542. }), { surfaceOp: 'append' })
  543. await expectCode(turnless.controller.edit(request(lone.seq, lone.seq), new AbortController().signal))
  544. await turnless.ctx.fiber.dispose()
  545. const stepLess = await harness()
  546. stepLess.session.append('turn/start', { turn: 1 })
  547. const noStep = stepLess.session.append('user/message', createUserMessage({
  548. content: [{ type: 'text', text: 'no step' }], source: { kind: 'user' },
  549. }), { surfaceOp: 'append' })
  550. await expectCode(stepLess.controller.edit(request(noStep.seq, noStep.seq), new AbortController().signal))
  551. await stepLess.ctx.fiber.dispose()
  552. const steered = await harness()
  553. const opening = appendTurn(steered.session, 1, 'opening', 'answer')
  554. steered.session.append('turn/start', { turn: 2 })
  555. steered.session.append('step/start', { turn: 2, step: 1 })
  556. const current = steered.session.append('user/message', createUserMessage({
  557. content: [{ type: 'text', text: 'current' }], source: { kind: 'user' },
  558. }), { surfaceOp: 'append' })
  559. steered.session.append('step/end', { turn: 2, step: 1 })
  560. steered.session.append('step/start', { turn: 2, step: 2 })
  561. const steering = steered.session.append('user/message', createUserMessage({
  562. content: [{ type: 'text', text: 'steering' }], source: { kind: 'user' },
  563. }), { surfaceOp: 'append' })
  564. await expectCode(steered.controller.edit(request(steering.seq, steering.seq), new AbortController().signal))
  565. expect(opening.seq).toBeLessThan(current.seq)
  566. await steered.ctx.fiber.dispose()
  567. })
  568. })
  569. describe('session.edit with AgentLoop', () => {
  570. it('runs the edited turn before preserved Queue work through the real loop', async () => {
  571. const ctx = new Context()
  572. await ctx.plugin(LlmRuntime)
  573. await ctx.plugin(SessionStore)
  574. await ctx.plugin(SessionProjectionRegistry)
  575. await ctx.plugin(SystemPrompt, { persona: '' })
  576. await ctx.plugin(ToolRuntime)
  577. await ctx.plugin(AgentRegistry)
  578. await ctx.plugin(AgentLoop, { agents: [] })
  579. ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
  580. const adapter = new MockAdapter([
  581. textResponse('old answer'),
  582. textResponse('edited answer'),
  583. textResponse('queued answer'),
  584. ])
  585. ctx.llm.registerAdapter(['fixture'], adapter)
  586. const remote = createSessionTestRemote(ctx, {
  587. defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
  588. cwd: '/workspace',
  589. })
  590. const agent = await ctx.agentLoop.create(SessionId('real-edit'), {
  591. provider: 'fixture', model: 'fixture-model',
  592. })
  593. agent.followup(createUserMessage({
  594. content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
  595. }))
  596. await agent.whenIdle()
  597. const target = agent.session.snapshotEvents().find(event => event.type === 'user/message')
  598. if (target?.type !== 'user/message') throw new Error('missing original prompt')
  599. agent.inbox.append('next-turn', createUserMessage({
  600. content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
  601. }))
  602. const result = await remote.edit({
  603. requestId: 'real-edit-request' as SessionRequestId,
  604. sessionId: agent.id,
  605. messageSeq: target.seq,
  606. expectedLastUserSeq: target.seq,
  607. text: 'edited',
  608. clientTimeZone: 'UTC',
  609. })
  610. expect(result).toMatchObject({ ok: true, value: { accepted: true } })
  611. await agent.whenIdle()
  612. const requests = adapter.requests.map(request => JSON.stringify(request.messages))
  613. expect(requests).toHaveLength(3)
  614. expect(requests[1]).toContain('edited')
  615. expect(requests[1]).not.toContain('original')
  616. expect(requests[1]).not.toContain('queued')
  617. expect(requests[2]).toContain('queued')
  618. expect(agent.session.deriveMessages().map(message => message.content)).toEqual([
  619. [{ type: 'text', text: 'edited' }],
  620. [{ type: 'text', text: 'edited answer' }],
  621. [{ type: 'text', text: 'queued' }],
  622. [{ type: 'text', text: 'queued answer' }],
  623. ])
  624. await ctx.fiber.dispose()
  625. })
  626. it('interrupts a running turn before admitting the edited rerun', async () => {
  627. const ctx = new Context()
  628. await ctx.plugin(LlmRuntime)
  629. await ctx.plugin(SessionStore)
  630. await ctx.plugin(SessionProjectionRegistry)
  631. await ctx.plugin(SystemPrompt, { persona: '' })
  632. await ctx.plugin(ToolRuntime)
  633. await ctx.plugin(AgentRegistry)
  634. await ctx.plugin(AgentLoop, { agents: [] })
  635. ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
  636. const adapter = new MockAdapter(['hang', textResponse('edited answer')])
  637. ctx.llm.registerAdapter(['fixture'], adapter)
  638. const remote = createSessionTestRemote(ctx, {
  639. defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
  640. cwd: '/workspace',
  641. })
  642. const agent = await ctx.agentLoop.create(SessionId('running-edit'), {
  643. provider: 'fixture', model: 'fixture-model',
  644. })
  645. agent.followup(createUserMessage({
  646. content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
  647. }))
  648. await vi.waitFor(() => {
  649. expect(adapter.requests).toHaveLength(1)
  650. })
  651. const target = agent.session.snapshotEvents().find(event => event.type === 'user/message')
  652. if (target?.type !== 'user/message') throw new Error('missing running prompt')
  653. const result = await remote.edit({
  654. requestId: 'running-edit-request' as SessionRequestId,
  655. sessionId: agent.id,
  656. messageSeq: target.seq,
  657. expectedLastUserSeq: target.seq,
  658. text: 'edited while running',
  659. })
  660. expect(result).toMatchObject({ ok: true })
  661. await agent.whenIdle()
  662. expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end').map(event =>
  663. event.type === 'turn/end' ? event.data.reason.kind : '')).toEqual(['aborted', 'completed'])
  664. expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('edited while running')
  665. expect(JSON.stringify(adapter.requests[1]?.messages)).not.toContain('original')
  666. await ctx.fiber.dispose()
  667. })
  668. })