Files
deepseek-harness/packages/api/session-controller/tests/session-edit.host.spec.ts
T

724 lines
31 KiB
TypeScript

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()
})
})