| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723 |
- import { Context } from '@deepseek-ai/cordis'
- import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
- import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
- import LlmRuntime, { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
- import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
- import type { Session, SessionEvent, SurfaceIntent } from '@deepseek-ai/dsh-session'
- import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import ToolRuntime from '@deepseek-ai/dsh-tools'
- import AgentLoop from '@deepseek-ai/dsh-agent-loop'
- import { describe, expect, it, vi } from 'vitest'
- import { ApiSessionAgentController } from '../src/agent.ts'
- import { SessionCommandController } from '../src/commands.ts'
- import type { SessionEditRequest, SessionRequestId } from '../src/types.ts'
- import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts'
- import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
- function appendTurn(session: Session, turn: number, prompt: string, answer: string): SessionEvent<'user/message'> {
- session.append('turn/start', { turn })
- session.append('step/start', { turn, step: 1 })
- const user = session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: prompt }],
- source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- session.append('assistant/message', {
- turn,
- step: 1,
- message: createAssistantMessage({
- content: [{ type: 'text', text: answer }],
- source: { provider: 'fixture', model: 'fixture-model' },
- }),
- }, { surfaceOp: 'append', sourceEventSeqs: [] })
- session.append('step/end', { turn, step: 1 })
- session.append('turn/end', { turn, reason: { kind: 'completed' } })
- return user
- }
- async function harness(
- running = false,
- inputModalities: readonly ('text' | 'image')[] | null = ['text', 'image'],
- providers: readonly string[] = ['fixture'],
- ): Promise<{
- ctx: Context
- controller: SessionCommandController
- agent: Agent
- session: Session
- sent: Array<{
- message: Parameters<Agent['send']>[0]
- followingMessages: readonly Parameters<Agent['send']>[0][]
- intent: SurfaceIntent | undefined
- position: string | undefined
- }>
- cancel: ReturnType<typeof vi.fn>
- }> {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(AgentRegistry)
- installSessionReadTestServices(ctx)
- ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
- ctx.provide('llm', {
- listProviders: () => providers.map(id => ({ id })),
- resolveModelInfo: () => Promise.resolve(inputModalities === null ? {} : { inputModalities }),
- } as never)
- ctx.provide('agentDefaultModel', {
- currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
- saveSelection: () => Promise.resolve(),
- } as never)
- const session = ctx.sessions.create(SessionId('edit-session'), { meta: { cwd: '/workspace' } })
- const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
- let status: Agent['status'] = running ? 'running' : 'idle'
- let nextTurn = 100
- const sent: Array<{
- message: Parameters<Agent['send']>[0]
- followingMessages: readonly Parameters<Agent['send']>[0][]
- intent: SurfaceIntent | undefined
- position: string | undefined
- }> = []
- const agent = {
- id: session.id,
- options: { provider: 'fixture', model: 'fixture-model' },
- session,
- inbox,
- get status() { return status },
- ctx,
- send(message, _target, _wakeup, options) {
- sent.push({
- message,
- followingMessages: options?.followingMessages ?? [],
- intent: options?.surfaceIntent,
- position: options?.position,
- })
- const turn = nextTurn
- nextTurn += 1
- session.append('turn/start', { turn })
- session.append('step/start', { turn, step: 1 })
- session.append('user/message', message, options?.surfaceIntent ?? { surfaceOp: 'append' })
- for (const following of options?.followingMessages ?? []) {
- session.append('user/message', following, { surfaceOp: 'append' })
- }
- session.append('step/end', { turn, step: 1 })
- session.append('turn/end', { turn, reason: { kind: 'completed' } })
- },
- followup: vi.fn(),
- steer: vi.fn(),
- inject: vi.fn(),
- cancel: vi.fn(() => {
- status = 'idle'
- ctx.emit('agent/status', { agent, status: 'idle' })
- }),
- runMaintenance: async operation => operation(new AbortController().signal),
- whenIdle: () => Promise.resolve(),
- } satisfies Agent
- const cancel = vi.mocked(agent.cancel)
- ctx.agents.register(agent)
- const selection: ModelSelectionRef = {
- current: { provider: 'fixture', model: 'fixture-model' },
- assembled: undefined,
- }
- const agents = {
- resolveAgent: () => Promise.resolve({ agent }),
- selectionFor: () => selection,
- serializeImageAdmission: (_agent: Agent, operation: () => Promise<unknown>) => operation(),
- } as unknown as ApiSessionAgentController
- return {
- ctx,
- controller: new SessionCommandController(ctx, agents, '/workspace'),
- agent,
- session,
- sent,
- cancel,
- }
- }
- function request(
- messageSeq: number,
- expectedLastUserSeq: number,
- text = 'edited',
- clientTimeZone: string | undefined = 'UTC',
- ): SessionEditRequest {
- return {
- requestId: 'edit-request' as SessionRequestId,
- sessionId: SessionId('edit-session'),
- messageSeq,
- expectedLastUserSeq,
- text,
- ...(clientTimeZone === undefined ? {} : { clientTimeZone }),
- }
- }
- describe('session.edit', () => {
- it.each([false, true])('replaces the latest turn and prioritizes the rerun (running=%s)', async (running) => {
- const fixture = await harness(running)
- appendTurn(fixture.session, 1, 'first', 'first answer')
- const second = appendTurn(fixture.session, 2, 'second', 'second answer')
- fixture.agent.inbox.append('next-turn', createUserMessage({
- content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
- }))
- const result = await fixture.controller.edit(request(second.seq, second.seq), new AbortController().signal)
- expect(result).toMatchObject({ accepted: true })
- expect(fixture.sent).toHaveLength(1)
- expect(fixture.sent[0]).toMatchObject({
- message: { content: [{ type: 'text', text: 'edited' }] },
- position: 'front',
- intent: {
- surfaceOp: { op: 'replace', start: second.seq },
- conversationOp: { op: 'replace', start: second.seq - 2 },
- },
- })
- expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
- [{ type: 'text', text: 'first' }],
- [{ type: 'text', text: 'first answer' }],
- [{ type: 'text', text: 'edited' }],
- ])
- expect(fixture.cancel).toHaveBeenCalledTimes(running ? 1 : 0)
- if (running) expect(fixture.cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
- expect(fixture.agent.inbox.nextTurn.map(message => message.content)).toEqual([
- [{ type: 'text', text: 'queued' }],
- ])
- await fixture.ctx.fiber.dispose()
- })
- it('preserves non-text blocks and permits unchanged text', async () => {
- const fixture = await harness()
- fixture.session.append('turn/start', { turn: 1 })
- fixture.session.append('step/start', { turn: 1, step: 1 })
- const prompt = fixture.session.append('user/message', createUserMessage({
- content: [
- { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
- { type: 'text', text: 'same' },
- { type: 'text', text: ' second block' },
- ],
- source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- fixture.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'referenced context' }],
- source: {
- kind: 'session-reference',
- form: 'recall',
- version: 1,
- references: [],
- } as never,
- }), { surfaceOp: 'append' })
- fixture.session.append('step/end', { turn: 1, step: 1 })
- fixture.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await fixture.controller.edit(request(prompt.seq, prompt.seq, 'same second block'), new AbortController().signal)
- expect(fixture.sent[0]?.message.content).toEqual([
- { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
- { type: 'text', text: 'same second block' },
- ])
- expect(fixture.sent[0]?.followingMessages).toEqual([
- expect.objectContaining({ content: [{ type: 'text', text: 'referenced context' }] }),
- ])
- expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
- [
- { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
- { type: 'text', text: 'same second block' },
- ],
- [{ type: 'text', text: 'referenced context' }],
- ])
- await fixture.ctx.fiber.dispose()
- })
- it('rejects a preserved image before interrupting a text-only model', async () => {
- const fixture = await harness(true, ['text'])
- fixture.session.append('turn/start', { turn: 1 })
- fixture.session.append('step/start', { turn: 1, step: 1 })
- const prompt = fixture.session.append('user/message', createUserMessage({
- content: [
- { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
- { type: 'text', text: 'describe' },
- ],
- source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- await expect(fixture.controller.edit(
- request(prompt.seq, prompt.seq, 'describe again'),
- new AbortController().signal,
- )).rejects.toMatchObject({ code: 'session/attachment-invalid' })
- expect(fixture.cancel).not.toHaveBeenCalled()
- expect(fixture.sent).toEqual([])
- await fixture.ctx.fiber.dispose()
- })
- it('accepts a preserved image when the current model declares no modality list', async () => {
- const fixture = await harness(false, null)
- fixture.session.append('turn/start', { turn: 1 })
- fixture.session.append('step/start', { turn: 1, step: 1 })
- const prompt = fixture.session.append('user/message', createUserMessage({
- content: [
- { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
- { type: 'text', text: 'describe' },
- ],
- source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- await expect(fixture.controller.edit(
- request(prompt.seq, prompt.seq, 'describe again'),
- new AbortController().signal,
- )).resolves.toMatchObject({ accepted: true })
- await fixture.ctx.fiber.dispose()
- })
- it('rejects invalid timezone and unavailable-model requests before interruption', async () => {
- const invalidZone = await harness(true)
- const zoneTarget = appendTurn(invalidZone.session, 1, 'prompt', 'answer')
- await expect(invalidZone.controller.edit({
- ...request(zoneTarget.seq, zoneTarget.seq),
- clientTimeZone: 'not/a-zone',
- }, new AbortController().signal)).rejects.toMatchObject({ code: 'session/invalid-time-zone' })
- expect(invalidZone.cancel).not.toHaveBeenCalled()
- await invalidZone.ctx.fiber.dispose()
- const unavailable = await harness(true, ['text'], [])
- const modelTarget = appendTurn(unavailable.session, 1, 'prompt', 'answer')
- await expect(unavailable.controller.edit(
- request(modelTarget.seq, modelTarget.seq),
- new AbortController().signal,
- )).rejects.toMatchObject({ code: 'session/model-unavailable' })
- expect(unavailable.cancel).not.toHaveBeenCalled()
- await unavailable.ctx.fiber.dispose()
- })
- it('honors cancellation before resolving the target Session', async () => {
- const fixture = await harness()
- const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
- const controller = new AbortController()
- controller.abort(new Error('request stopped'))
- await expect(fixture.controller.edit(
- request(target.seq, target.seq),
- controller.signal,
- )).rejects.toThrow('request stopped')
- expect(fixture.sent).toEqual([])
- await fixture.ctx.fiber.dispose()
- })
- it('maps an admission race to stale and preserves an unexpected send failure as internal', async () => {
- const raced = await harness()
- const racedTarget = appendTurn(raced.session, 1, 'prompt', 'answer')
- vi.spyOn(raced.agent, 'runMaintenance').mockImplementation(async (operation) => {
- appendTurn(raced.session, 2, 'newer prompt', 'newer answer')
- return operation(new AbortController().signal)
- })
- await expect(raced.controller.edit(
- request(racedTarget.seq, racedTarget.seq),
- new AbortController().signal,
- )).rejects.toMatchObject({ code: 'session/edit-stale' })
- await raced.ctx.fiber.dispose()
- const failedSend = await harness()
- const sendTarget = appendTurn(failedSend.session, 1, 'prompt', 'answer')
- const failure = new Error('send failed')
- vi.spyOn(failedSend.agent, 'send').mockImplementation(() => { throw failure })
- await expect(failedSend.controller.edit(
- request(sendTarget.seq, sendTarget.seq),
- new AbortController().signal,
- )).rejects.toMatchObject({ code: 'gateway/internal', cause: failure })
- await failedSend.ctx.fiber.dispose()
- })
- it.each([new Error('cancel failed'), 'cancel failed'])('preserves a thrown cancellation (%s) as an internal failure', async (reason) => {
- const fixture = await harness(true)
- const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
- vi.spyOn(fixture.agent, 'cancel').mockImplementation(() => { throw reason })
- await expect(fixture.controller.edit(
- request(target.seq, target.seq),
- new AbortController().signal,
- )).rejects.toMatchObject({ code: 'gateway/internal' })
- await fixture.ctx.fiber.dispose()
- })
- it.each([false, true])('reports an occupied maintenance slot as busy (running=%s)', async (running) => {
- const fixture = await harness(running)
- const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
- vi.spyOn(fixture.agent, 'runMaintenance').mockImplementation(() => { throw new Error('maintenance busy') })
- await expect(fixture.controller.edit(
- request(target.seq, target.seq),
- new AbortController().signal,
- )).rejects.toMatchObject({
- code: 'session/agent-busy',
- details: { reason: 'Error: maintenance busy' },
- })
- await fixture.ctx.fiber.dispose()
- })
- it.each([new Error('request stopped'), 'request stopped'])('honors caller cancellation while waiting for idle (%s)', async (reason) => {
- const fixture = await harness(true)
- const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
- const cancel = vi.spyOn(fixture.agent, 'cancel').mockImplementation(() => {})
- const controller = new AbortController()
- const editing = fixture.controller.edit(request(target.seq, target.seq), controller.signal)
- await vi.waitFor(() => { expect(cancel).toHaveBeenCalledOnce() })
- fixture.ctx.emit('agent/status', { agent: {} as Agent, status: 'idle' })
- fixture.ctx.emit('agent/status', { agent: fixture.agent, status: 'running' })
- controller.abort(reason)
- await expect(editing).rejects.toThrow(reason instanceof Error ? reason.message : 'edit request aborted')
- await fixture.ctx.fiber.dispose()
- })
- it('rejects when the edit message is discarded or its claimed turn closes before commit', async () => {
- const discarded = await harness()
- const discardedTarget = appendTurn(discarded.session, 1, 'prompt', 'answer')
- vi.spyOn(discarded.agent, 'send').mockImplementation((message) => {
- discarded.ctx.emit('agent/inbox/claimed', { agent: {} as Agent, message, turn: 8 })
- discarded.ctx.emit('agent/inbox/claimed', {
- agent: discarded.agent,
- message: createUserMessage({ content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }),
- turn: 8,
- })
- discarded.ctx.emit('agent/inbox/discarded', { agent: {} as Agent, message })
- discarded.ctx.emit('agent/inbox/discarded', { agent: discarded.agent, message })
- })
- await expect(discarded.controller.edit(
- request(discardedTarget.seq, discardedTarget.seq),
- new AbortController().signal,
- )).rejects.toMatchObject({ code: 'session/edit-stale' })
- await discarded.ctx.fiber.dispose()
- const closed = await harness()
- const closedTarget = appendTurn(closed.session, 1, 'prompt', 'answer')
- const unrelatedSession = closed.ctx.sessions.create(SessionId('unrelated-edit-session'))
- vi.spyOn(closed.agent, 'send').mockImplementation((message) => {
- closed.ctx.emit('agent/inbox/claimed', { agent: closed.agent, message, turn: 9 })
- closed.ctx.emit('session/event', unrelatedSession, {
- type: 'turn/end', seq: SessionSeq(0), time: 0, data: { turn: 9, reason: { kind: 'blocked' } },
- })
- closed.session.append('turn/end', { turn: 9, reason: { kind: 'blocked' } })
- })
- await expect(closed.controller.edit(
- request(closedTarget.seq, closedTarget.seq),
- new AbortController().signal,
- )).rejects.toMatchObject({ code: 'session/edit-stale' })
- await closed.ctx.fiber.dispose()
- })
- it('rejects when the Agent is disposed before the replacement commits', async () => {
- const fixture = await harness()
- const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
- vi.spyOn(fixture.agent, 'send').mockImplementation(() => {
- fixture.ctx.emit('agent/disposed', { agent: {} as Agent })
- fixture.ctx.emit('agent/disposed', { agent: fixture.agent })
- })
- await expect(fixture.controller.edit(
- request(target.seq, target.seq),
- new AbortController().signal,
- )).rejects.toMatchObject({ code: 'session/edit-stale' })
- await fixture.ctx.fiber.dispose()
- })
- it('allows the current replacement message to be edited again', async () => {
- const fixture = await harness()
- const original = appendTurn(fixture.session, 1, 'original', 'answer')
- const first = await fixture.controller.edit(
- request(original.seq, original.seq, 'first edit'),
- new AbortController().signal,
- )
- const second = await fixture.controller.edit(
- request(first.messageSeq, first.messageSeq, 'second edit'),
- new AbortController().signal,
- )
- expect(second.messageSeq).toBeGreaterThan(first.messageSeq)
- expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
- [{ type: 'text', text: 'second edit' }],
- ])
- await fixture.ctx.fiber.dispose()
- })
- it('retains a later compaction checkpoint positioned before the edited turn', async () => {
- const fixture = await harness()
- const first = appendTurn(fixture.session, 1, 'first', 'first answer')
- const second = appendTurn(fixture.session, 2, 'second', 'second answer')
- const firstAnswer = fixture.session.snapshotEvents().find(event =>
- event.type === 'assistant/message' && event.data.turn === 1)
- if (firstAnswer?.type !== 'assistant/message') throw new Error('missing first answer')
- const checkpoint = fixture.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'summary' }],
- source: { kind: 'plugin', plugin: 'compact' },
- }), {
- surfaceOp: { op: 'replace', start: first.seq, end: firstAnswer.seq },
- sourceEventSeqs: [first.seq, firstAnswer.seq],
- })
- await fixture.controller.edit(request(second.seq, second.seq), new AbortController().signal)
- expect(fixture.sent[0]?.intent?.surfaceOp).toMatchObject({
- op: 'replace',
- start: second.seq,
- })
- expect(fixture.session.surface.nodes[0]).toBe(checkpoint.seq)
- expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
- [{ type: 'text', text: 'summary' }],
- [{ type: 'text', text: 'edited' }],
- ])
- await fixture.ctx.fiber.dispose()
- })
- it('accepts legacy turns whose opening user message precedes step/start', async () => {
- const fixture = await harness()
- fixture.session.append('turn/start', { turn: 1 })
- const prompt = fixture.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'legacy prompt' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- fixture.session.append('step/start', { turn: 1, step: 1 })
- fixture.session.append('step/end', { turn: 1, step: 1 })
- fixture.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await expect(fixture.controller.edit(
- request(prompt.seq, prompt.seq, 'edited legacy prompt', undefined),
- new AbortController().signal,
- )).resolves.toMatchObject({ accepted: true })
- expect(fixture.sent[0]?.message.content).toEqual([{ type: 'text', text: 'edited legacy prompt' }])
- await fixture.ctx.fiber.dispose()
- })
- it('rejects stale, shadowed, steering, empty, and malformed requests before admission', async () => {
- const fixture = await harness()
- const first = appendTurn(fixture.session, 1, 'first', 'first answer')
- const second = appendTurn(fixture.session, 2, 'second', 'second answer')
- const expectCode = async (value: Promise<unknown>, code: string): Promise<void> => {
- await expect(value).rejects.toMatchObject({ code })
- }
- await expectCode(
- fixture.controller.edit(request(first.seq, first.seq), new AbortController().signal),
- 'session/edit-stale',
- )
- await expectCode(
- fixture.controller.edit(request(first.seq, second.seq), new AbortController().signal),
- 'session/edit-unavailable',
- )
- await expectCode(
- fixture.controller.edit(request(-1, second.seq), new AbortController().signal),
- 'gateway/bad-request',
- )
- await expectCode(
- fixture.controller.edit(request(first.seq, -1), new AbortController().signal),
- 'gateway/bad-request',
- )
- await expectCode(
- fixture.controller.edit(request(second.seq, second.seq, ''), new AbortController().signal),
- 'gateway/bad-request',
- )
- const rewritten = fixture.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' },
- }), {
- surfaceOp: { op: 'replace', start: first.seq, end: SessionSeq(second.seq + 1) },
- sourceEventSeqs: [
- first.seq,
- SessionSeq(first.seq + 1),
- second.seq,
- SessionSeq(second.seq + 1),
- ],
- conversationOp: {
- op: 'replace',
- start: SessionSeq(first.seq - 2),
- end: SessionSeq(fixture.session.seq - 1),
- },
- })
- await expectCode(
- fixture.controller.edit(request(first.seq, rewritten.seq), new AbortController().signal),
- 'session/edit-unavailable',
- )
- await fixture.ctx.fiber.dispose()
- })
- it('rejects the latest human message after compaction removes it from the current surface', async () => {
- const fixture = await harness()
- const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
- const answer = fixture.session.snapshotEvents().find(event =>
- event.type === 'assistant/message' && event.data.turn === 1)
- if (answer?.type !== 'assistant/message') throw new Error('missing answer')
- fixture.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'summary' }],
- source: { kind: 'plugin', plugin: 'compact' },
- }), {
- surfaceOp: { op: 'replace', start: target.seq, end: answer.seq },
- sourceEventSeqs: [target.seq, answer.seq],
- })
- await expect(fixture.controller.edit(
- request(target.seq, target.seq),
- new AbortController().signal,
- )).rejects.toMatchObject({ code: 'session/edit-unavailable' })
- await fixture.ctx.fiber.dispose()
- })
- it('rejects non-human, textless, turnless, steered, and step-less targets', async () => {
- const expectCode = async (value: Promise<unknown>): Promise<void> => {
- await expect(value).rejects.toMatchObject({ code: 'session/edit-unavailable' })
- }
- const nonHuman = await harness()
- nonHuman.session.append('turn/start', { turn: 1 })
- nonHuman.session.append('step/start', { turn: 1, step: 1 })
- const context = nonHuman.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' },
- }), { surfaceOp: 'append' })
- const human = nonHuman.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'human' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- await expectCode(nonHuman.controller.edit(request(context.seq, human.seq), new AbortController().signal))
- await nonHuman.ctx.fiber.dispose()
- const textless = await harness()
- textless.session.append('turn/start', { turn: 1 })
- textless.session.append('step/start', { turn: 1, step: 1 })
- const image = textless.session.append('user/message', createUserMessage({
- content: [{ type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }],
- source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- await expectCode(textless.controller.edit(request(image.seq, image.seq), new AbortController().signal))
- await textless.ctx.fiber.dispose()
- const turnless = await harness()
- const lone = turnless.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'lone' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- await expectCode(turnless.controller.edit(request(lone.seq, lone.seq), new AbortController().signal))
- await turnless.ctx.fiber.dispose()
- const stepLess = await harness()
- stepLess.session.append('turn/start', { turn: 1 })
- const noStep = stepLess.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'no step' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- await expectCode(stepLess.controller.edit(request(noStep.seq, noStep.seq), new AbortController().signal))
- await stepLess.ctx.fiber.dispose()
- const steered = await harness()
- const opening = appendTurn(steered.session, 1, 'opening', 'answer')
- steered.session.append('turn/start', { turn: 2 })
- steered.session.append('step/start', { turn: 2, step: 1 })
- const current = steered.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'current' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- steered.session.append('step/end', { turn: 2, step: 1 })
- steered.session.append('step/start', { turn: 2, step: 2 })
- const steering = steered.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'steering' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- await expectCode(steered.controller.edit(request(steering.seq, steering.seq), new AbortController().signal))
- expect(opening.seq).toBeLessThan(current.seq)
- await steered.ctx.fiber.dispose()
- })
- })
- describe('session.edit with AgentLoop', () => {
- it('runs the edited turn before preserved Queue work through the real loop', async () => {
- const ctx = new Context()
- await ctx.plugin(LlmRuntime)
- await ctx.plugin(SessionStore)
- await ctx.plugin(SessionProjectionRegistry)
- await ctx.plugin(SystemPrompt, { persona: '' })
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(AgentLoop, { agents: [] })
- ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
- const adapter = new MockAdapter([
- textResponse('old answer'),
- textResponse('edited answer'),
- textResponse('queued answer'),
- ])
- ctx.llm.registerAdapter(['fixture'], adapter)
- const remote = createSessionTestRemote(ctx, {
- defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
- cwd: '/workspace',
- })
- const agent = await ctx.agentLoop.create(SessionId('real-edit'), {
- provider: 'fixture', model: 'fixture-model',
- })
- agent.followup(createUserMessage({
- content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
- }))
- await agent.whenIdle()
- const target = agent.session.snapshotEvents().find(event => event.type === 'user/message')
- if (target?.type !== 'user/message') throw new Error('missing original prompt')
- agent.inbox.append('next-turn', createUserMessage({
- content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
- }))
- const result = await remote.edit({
- requestId: 'real-edit-request' as SessionRequestId,
- sessionId: agent.id,
- messageSeq: target.seq,
- expectedLastUserSeq: target.seq,
- text: 'edited',
- clientTimeZone: 'UTC',
- })
- expect(result).toMatchObject({ ok: true, value: { accepted: true } })
- await agent.whenIdle()
- const requests = adapter.requests.map(request => JSON.stringify(request.messages))
- expect(requests).toHaveLength(3)
- expect(requests[1]).toContain('edited')
- expect(requests[1]).not.toContain('original')
- expect(requests[1]).not.toContain('queued')
- expect(requests[2]).toContain('queued')
- expect(agent.session.deriveMessages().map(message => message.content)).toEqual([
- [{ type: 'text', text: 'edited' }],
- [{ type: 'text', text: 'edited answer' }],
- [{ type: 'text', text: 'queued' }],
- [{ type: 'text', text: 'queued answer' }],
- ])
- await ctx.fiber.dispose()
- })
- it('interrupts a running turn before admitting the edited rerun', async () => {
- const ctx = new Context()
- await ctx.plugin(LlmRuntime)
- await ctx.plugin(SessionStore)
- await ctx.plugin(SessionProjectionRegistry)
- await ctx.plugin(SystemPrompt, { persona: '' })
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(AgentLoop, { agents: [] })
- ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
- const adapter = new MockAdapter(['hang', textResponse('edited answer')])
- ctx.llm.registerAdapter(['fixture'], adapter)
- const remote = createSessionTestRemote(ctx, {
- defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
- cwd: '/workspace',
- })
- const agent = await ctx.agentLoop.create(SessionId('running-edit'), {
- provider: 'fixture', model: 'fixture-model',
- })
- agent.followup(createUserMessage({
- content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
- }))
- await vi.waitFor(() => {
- expect(adapter.requests).toHaveLength(1)
- })
- const target = agent.session.snapshotEvents().find(event => event.type === 'user/message')
- if (target?.type !== 'user/message') throw new Error('missing running prompt')
- const result = await remote.edit({
- requestId: 'running-edit-request' as SessionRequestId,
- sessionId: agent.id,
- messageSeq: target.seq,
- expectedLastUserSeq: target.seq,
- text: 'edited while running',
- })
- expect(result).toMatchObject({ ok: true })
- await agent.whenIdle()
- expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end').map(event =>
- event.type === 'turn/end' ? event.data.reason.kind : '')).toEqual(['aborted', 'completed'])
- expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('edited while running')
- expect(JSON.stringify(adapter.requests[1]?.messages)).not.toContain('original')
- await ctx.fiber.dispose()
- })
- })
|