|
|
@@ -0,0 +1,723 @@
|
|
|
+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()
|
|
|
+ })
|
|
|
+})
|