mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
724 lines
31 KiB
TypeScript
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()
|
|
})
|
|
})
|