| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453 |
- import { Context } from '@deepseek-ai/cordis'
- import AgentRegistry from '@deepseek-ai/dsh-agent'
- import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
- import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
- import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
- import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
- import SessionStore, {
- SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq,
- } from '@deepseek-ai/dsh-session'
- import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
- import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
- import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
- import { describe, expect, it, vi } from 'vitest'
- import { ApiSessionAgentController } from '../src/agent.ts'
- import { SessionCommandController } from '../src/commands.ts'
- import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
- import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
- async function commandHarness(
- childMode?: 'continuable' | 'seeded-continuable' | 'seed-only' | 'one-shot' | 'unknown' | 'corrupt',
- ): Promise<{
- ctx: Context
- controller: SessionCommandController
- agent: Agent
- inbox: Inbox
- steer: ReturnType<typeof vi.fn>
- cancel: ReturnType<typeof vi.fn>
- }> {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(AgentRegistry)
- installSessionReadTestServices(ctx)
- ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
- const sessionId = SessionId('commands-session')
- const ancestor = Session.create(SessionId('ancestor'))
- ancestor.append('subagent/descriptor', snapshotSubagentDescriptor({
- mode: 'continuable', provider: 'test', label: 'ancestor',
- }))
- // A seeded child inherits exactly the ancestor prefix; its own descriptor
- // is appended after creation, as the continuation manager does. `seed-only`
- // never appends one: the identity folds as continuable, but from the
- // inherited prefix rather than this Session's own suffix.
- const lineage = childMode === 'seeded-continuable' || childMode === 'seed-only'
- ? ancestor.snapshotEvents()
- : undefined
- const session = ctx.sessions.create(sessionId, {
- ...lineage === undefined ? {} : { seed: lineage, inheritedEventCount: SessionLogOffset(lineage.length) },
- meta: {
- cwd: '/workspace',
- ...(childMode === undefined ? {} : {
- origin: 'subagent' as const,
- parentSession: SessionId('offline-parent'),
- }),
- ...lineage === undefined ? {} : { isSeeded: true },
- },
- })
- if (childMode === 'continuable' || childMode === 'seeded-continuable') {
- session.append('subagent/descriptor', snapshotSubagentDescriptor({
- mode: 'continuable', provider: 'test', label: 'child',
- }))
- } else if (childMode === 'one-shot') {
- session.append('subagent/descriptor', snapshotSubagentDescriptor({
- mode: 'one-shot', provider: 'test', label: 'child',
- }))
- } else if (childMode === 'corrupt') {
- session.append('subagent/descriptor', {
- version: SUBAGENT_DESCRIPTOR_VERSION,
- mode: 'continuable',
- provider: 1,
- } as never)
- }
- const inbox = createInboxStub()
- const steer = vi.fn((message: UserMessage) => { inbox.append('next-step', message) })
- const cancel = vi.fn()
- const agent = {
- id: session.id,
- session,
- inbox,
- status: 'running',
- ctx,
- steer,
- followup: vi.fn(),
- cancel,
- } as unknown as Agent
- ctx.agents.register(agent)
- ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
- ctx.provide('agentDefaultModel', {
- currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
- saveSelection: () => Promise.resolve(),
- } as never)
- const selection: ModelSelectionRef = {
- current: { provider: 'fixture', model: 'fixture-model' },
- assembled: undefined,
- }
- const agents = {
- resolveAgent: () => Promise.resolve({ agent }),
- selectionFor: () => selection,
- serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
- composeAgent: () => Promise.resolve({ setup: () => {} }),
- } as unknown as ApiSessionAgentController
- return {
- ctx,
- controller: new SessionCommandController(ctx, agents, '/workspace'),
- agent,
- inbox,
- steer,
- cancel,
- }
- }
- async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
- await expect(operation).rejects.toMatchObject({ code })
- }
- describe('Session queue commands', () => {
- it('edits, removes, steers, and rejects stale queue occurrences', async () => {
- const { ctx, controller, agent, inbox, steer, cancel } = await commandHarness()
- const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
- const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
- inbox.append('next-turn', queued)
- inbox.append('next-step', nextStep)
- await expectFailure(Promise.resolve().then(() => controller.updateQueue({
- sessionId: agent.id,
- itemId: queued.id,
- action: {
- kind: 'edit',
- content: [{
- type: 'image',
- attachment: {
- attachmentId: AttachmentId('att-edit'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
- },
- }],
- },
- })), 'session/attachment-invalid')
- for (const content of [[], [{ type: 'text' as const, text: ' \t\n' }]]) {
- await expectFailure(Promise.resolve().then(() => controller.updateQueue({
- sessionId: agent.id,
- itemId: queued.id,
- action: { kind: 'edit', content },
- })), 'gateway/bad-request')
- }
- expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'queued' }])
- await expectFailure(Promise.resolve().then(() => controller.updateQueue({
- sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' },
- })), 'session/queue-item-not-found')
- await expectFailure(Promise.resolve().then(() => controller.updateQueue({
- sessionId: agent.id, itemId: MessageId('missing'), action: { kind: 'remove' },
- })), 'session/queue-item-not-found')
- await expectFailure(Promise.resolve().then(() => controller.updateQueue({
- sessionId: agent.id, itemId: nextStep.id, action: { kind: 'steer' },
- })), 'session/steer-unavailable')
- Object.assign(agent, { status: 'idle' })
- await expectFailure(Promise.resolve().then(() => controller.updateQueue({
- sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
- })), 'session/steer-unavailable')
- expect(controller.updateQueue({
- sessionId: agent.id,
- itemId: queued.id,
- action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
- })).toEqual({ accepted: true })
- expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }])
- // An edit rewrites content in place, so the occurrence a client addressed
- // by id stays addressable.
- expect(inbox.nextTurn[0]?.id).toBe(queued.id)
- expect(controller.updateQueue({
- sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' },
- })).toEqual({ accepted: true })
- Object.assign(agent, { status: 'running' })
- const steered = inbox.nextTurn[0]
- if (steered === undefined) throw new Error('missing edited queue item')
- expect(controller.updateQueue({
- sessionId: agent.id, itemId: steered.id, action: { kind: 'steer' },
- })).toEqual({ accepted: true })
- expect(steer).toHaveBeenCalledWith(steered)
- const queuedFile = createUserMessage({
- content: [{
- type: 'file',
- attachment: { attachmentId: AttachmentId('file-queued'), name: 'queued.txt', bytes: 6 },
- }],
- source: { kind: 'user', rpcId: 'file-rpc' as never },
- })
- inbox.append('next-turn', queuedFile)
- expect(controller.updateQueue({
- sessionId: agent.id, itemId: queuedFile.id, action: { kind: 'steer' },
- })).toEqual({ accepted: true })
- expect(steer).toHaveBeenLastCalledWith(queuedFile)
- expect(queuedFile).toMatchObject({
- source: { kind: 'user', rpcId: 'file-rpc' },
- content: [{ type: 'file', attachment: { name: 'queued.txt', bytes: 6 } }],
- })
- await expectFailure(Promise.resolve().then(() => controller.cancel({
- sessionId: SessionId('missing'),
- })), 'session/not-found')
- expect(controller.cancel({ sessionId: agent.id })).toEqual({ accepted: true })
- expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
- await ctx.fiber.dispose()
- })
- it.each(['continuable', 'seeded-continuable'] as const)(
- 'mutates both inbox destinations of a live %s child while its parent is offline',
- async (childMode) => {
- const { ctx, controller, agent, inbox, steer } = await commandHarness(childMode)
- const queued = createUserMessage({
- content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
- })
- const context = createUserMessage({
- content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' },
- })
- inbox.append('next-turn', queued)
- inbox.append('next-step', context)
- expect(controller.updateQueue({
- sessionId: agent.id,
- itemId: context.id,
- action: { kind: 'edit', content: [{ type: 'text', text: 'edited context' }] },
- })).toEqual({ accepted: true })
- const editedContext = inbox.nextStep[0]
- expect(editedContext).toMatchObject({
- content: [{ type: 'text', text: 'edited context' }],
- source: context.source,
- })
- expect(editedContext?.id).toBe(context.id)
- if (editedContext === undefined) throw new Error('missing edited context')
- expect(controller.updateQueue({
- sessionId: agent.id, itemId: editedContext.id, action: { kind: 'remove' },
- })).toEqual({ accepted: true })
- expect(controller.updateQueue({
- sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
- })).toEqual({ accepted: true })
- expect(steer).toHaveBeenCalledWith(queued)
- await ctx.fiber.dispose()
- },
- )
- it('removes the selected message before handing it to Agent steering', async () => {
- const { ctx, controller, agent, inbox, steer } = await commandHarness('continuable')
- const first = createUserMessage({
- content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
- })
- const second = createUserMessage({
- content: [{ type: 'text', text: 'second' }], source: { kind: 'user' },
- })
- inbox.append('next-turn', first)
- inbox.append('next-turn', second)
- // Stand in for the Agent's cancellation-convergence destination; the
- // command must accept whichever boundary `Agent.steer()` selects.
- steer.mockImplementation((message: UserMessage) => { inbox.append('next-turn', message) })
- expect(controller.updateQueue({
- sessionId: agent.id, itemId: first.id, action: { kind: 'steer' },
- })).toEqual({ accepted: true })
- expect(steer).toHaveBeenCalledWith(first)
- // Ordering proves the removal happened before delivery rather than after.
- expect(inbox.nextTurn).toEqual([second, first])
- expect(inbox.nextStep).toEqual([])
- await ctx.fiber.dispose()
- })
- it('keeps one-shot, seed-only, missing, and malformed child descriptors behind the ownership fence', async () => {
- for (const mode of ['one-shot', 'seed-only', 'unknown', 'corrupt'] as const) {
- const { ctx, controller, agent, inbox } = await commandHarness(mode)
- const queued = createUserMessage({
- content: [{ type: 'text', text: mode }], source: { kind: 'user' },
- })
- inbox.append('next-turn', queued)
- await expectFailure(Promise.resolve().then(() => controller.updateQueue({
- sessionId: agent.id, itemId: queued.id, action: { kind: 'remove' },
- })), 'session/agent-busy')
- expect(inbox.nextTurn).toEqual([queued])
- await ctx.fiber.dispose()
- }
- })
- })
- function imageRef(id: string): ImageAttachmentRef {
- return {
- attachmentId: AttachmentId(id),
- mediaType: 'image/png',
- bytes: 1,
- width: 1,
- height: 1,
- }
- }
- function event(type: string, seq: SessionSeq, data: unknown): SessionEvent {
- return { type, seq, time: seq + 1, data } as SessionEvent
- }
- async function persistedController(
- events: SessionEvent[],
- readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>,
- ): Promise<{ ctx: Context; controller: SessionCommandController; sessionId: SessionId }> {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const sessionId = SessionId('cold-attachment')
- const meta: SessionHeader = {
- version: SESSION_FORMAT_VERSION,
- id: sessionId,
- createdAt: 1,
- cwd: '/workspace',
- isSeeded: false,
- }
- ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
- list: () => Promise.resolve([meta]),
- inspect: () => Promise.resolve({
- meta,
- inheritedEventCount: SessionLogOffset(0),
- events,
- }),
- }) as never)
- installSessionReadTestServices(ctx)
- ctx.provide('attachments', { readImage } as never)
- const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController
- return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId }
- }
- describe('Session attachment authorization', () => {
- it('finds references in direct, message, inserted, nested, and streamed content', async () => {
- const nested = imageRef('nested')
- const message = imageRef('message')
- const inserted = imageRef('inserted')
- const streamed = imageRef('streamed')
- const events: SessionEvent[] = [
- { ...event('fixture/direct', SessionSeq(0), {
- content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
- type: 'tool-result', content: [{ type: 'image', attachment: nested }],
- }],
- }), ignorable: true as const },
- {
- type: 'assistant/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append',
- data: {
- turn: 1,
- step: 1,
- stream: [],
- message: createAssistantMessage({
- content: [{ type: 'image', attachment: message }],
- source: { provider: 'fixture', model: 'fixture' },
- }),
- },
- },
- event('agent/inbox/spliced', SessionSeq(2), {
- target: 'next-turn',
- start: 0,
- inserted: [createUserMessage({
- content: [{ type: 'image', attachment: inserted }],
- source: { kind: 'user' },
- })],
- }),
- event('assistant/attempt', SessionSeq(3), {
- turn: 1,
- step: 1,
- stream: [
- {
- type: 'chunk',
- time: 3,
- chunk: { type: 'block-start', index: 0, blockType: 'text' },
- },
- {
- type: 'chunk',
- time: 3,
- chunk: { type: 'block-end', index: 0, block: { type: 'text', text: '' } },
- },
- ],
- }),
- event('assistant/attempt', SessionSeq(4), {
- turn: 1,
- step: 1,
- stream: [{
- type: 'chunk',
- time: 4,
- chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } },
- }],
- }),
- ]
- const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) }))
- const { ctx, controller, sessionId } = await persistedController(events, readImage)
- for (const ref of [nested, message, inserted, streamed]) {
- await expect(controller.attachment({ sessionId, attachmentId: ref.attachmentId }))
- .resolves.toEqual({ attachment: ref, data: 'AQ==' })
- }
- expect(readImage).toHaveBeenCalledTimes(4)
- await ctx.fiber.dispose()
- })
- it('maps missing persistence identities and attachment backend failures', async () => {
- const noPersistence = new Context()
- await noPersistence.plugin(SessionStore)
- installSessionReadTestServices(noPersistence)
- const noPersistenceController = new SessionCommandController(
- noPersistence,
- { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
- '/workspace',
- )
- await expectFailure(noPersistenceController.attachment({
- sessionId: SessionId('missing'), attachmentId: AttachmentId('att'),
- }), 'session/not-found')
- const missing = new Context()
- await missing.plugin(SessionStore)
- missing.provide('sessionPersistence', testSessionPersistence(missing, {
- list: () => Promise.resolve([]),
- inspect: vi.fn(),
- }) as never)
- installSessionReadTestServices(missing)
- const missingController = new SessionCommandController(
- missing,
- { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
- '/workspace',
- )
- await expectFailure(missingController.attachment({
- sessionId: SessionId('missing'), attachmentId: 'att' as never,
- }), 'session/not-found')
- for (const thrown of [
- new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'),
- new Error('backend offline'),
- ]) {
- const ref = imageRef(`failure-${thrown.name}`)
- const fixture = await persistedController(
- [event('fixture/content', SessionSeq(0), { content: [{ type: 'image', attachment: ref }] })],
- () => Promise.reject(thrown),
- )
- await expectFailure(fixture.controller.attachment({
- sessionId: fixture.sessionId,
- attachmentId: ref.attachmentId,
- }), thrown instanceof AttachmentError ? 'session/attachment-invalid' : 'gateway/internal')
- await fixture.ctx.fiber.dispose()
- }
- })
- it('maps a cold observation failure to an internal authorization error', async () => {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- installSessionReadTestServices(ctx)
- vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
- const controller = new SessionCommandController(
- ctx,
- { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
- '/workspace',
- )
- await expectFailure(controller.attachment({
- sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'),
- }), 'gateway/internal')
- await ctx.fiber.dispose()
- })
- })
|