Session V4 reserves developer-role messages for incremental model-visible session changes. The role, tool addition/removal blocks, and deferred-loading marker need a durable representation before independent provider and UI integrations can emit or consume them. Known developer events must match an open turn and step; unsupported providers and UI consumers fail explicitly. Request headers already capture full tool definitions. Persisting another ToolSchema in each addition would duplicate Session information and create two definitions that could drift. Additions therefore store toolName, and the containing developer event uses headerSeq to select one earlier known request/header for all its additions. A same-name replacement keeps its historical definition identity through restart, fork and surface changes. Removals require no definition copy, and ordinary developer messages omit headerSeq. Generic sourceEventSeqs remains independent derivation metadata. The rejected inline tool field is optional never with @persistenceReserved. The schema extractor retains that uninhabited slot, so permitting a future value requires a format bump instead of being mistaken for compatible optional metadata. Unrelated optional fields remain additive and readable. Native and Session admission reject missing, forward, non-header, unknown, ambiguous and incomplete referenced definitions, and reject the retired inline tool field. Validate complete schema fields only for referenced definitions, preserving unrelated header behavior and extra JSON metadata. Unknown ignorable records remain opaque. Empty developer nodes retain their surface position without producing a message or shadowing the system head. No automatic emitter, provider activation, UI implementation, or speculative public resolver is added. Keep the core-owned developer source binding beside the introduced role, including the request-only type rejection and attribution round trips. Update consumer guards, SDK/snapshot handling, bilingual owner documentation, and the same unreleased V4 acknowledgement. Keep earlier tool/source migration work and its documentation in the parent. Preserve the existing recorded Cordis inspection generation from the original developer change. Integration keeps the final source layer as the sole parent and preserves master's Messages input filtering, empty-user omission, empty tool results, and system-update placement. Developer/deferred guards still run before conversion. Native V4 admission, header references, reservation declarations, and generic tooling match the validated marker candidate. Parent-owned fixture and generator fixes remain in the parent. Earlier feature validation passed full build, full lint, 41 doc-sync gates, and 16 hygiene gates. The unchanged native implementation retains that evidence. The focused Session/V4/request-input run passes 884 tests across 38 files with 100% statement, branch, function and line coverage for the V3-to-V4 package and Session surface. Consumer tests pass 482 with one existing skip; tool, message and optional-field tests pass 146. Cordis inspection and compaction replays pass in both source and built modes (two cases per mode). Ten invalid referenced-definition/unknown-header controls failed before their scoped guards and pass after them. Reservation validation: the actual generated developer root records tool as optional primitive never. Widening it requires a bump, while traceId remains additive and preserved; all 85 focused reader/classifier tests pass. Final-parent validation: 205 tests across eight provider, type, catalog, Session and native-format files pass. The preserved parent fixture verifies Messages input-history replay; runtime/declaration and parent-owned-file comparisons show no loss during the final rebase. Co-authored-by: Tianyi Cui <[email protected]>
482 lines
19 KiB
TypeScript
482 lines
19 KiB
TypeScript
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
|
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 type { ContextFormed } 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'
|
|
|
|
declare module '@deepseek-ai/dsh-llm' {
|
|
interface MessageSourceMap {
|
|
'test': { kind: 'test' } & ContextFormed
|
|
}
|
|
}
|
|
|
|
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
|
|
await 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: (id: SessionId) => Promise.resolve(id === agent.id
|
|
? { agent }
|
|
: { error: new RemoteError('session/not-found', 'missing', { sessionId: id }) }),
|
|
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('preserves the cold Agent resolver rejection', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
const error = new RemoteError('session/agent-busy', 'owned by a child', { reason: 'subagent-owned' })
|
|
const controller = new SessionCommandController(ctx, {
|
|
resolveAgent: () => Promise.resolve({ error }),
|
|
} as unknown as ApiSessionAgentController, '/workspace')
|
|
try {
|
|
await expect(controller.updateQueue({
|
|
sessionId: SessionId('cold-child'), itemId: MessageId('pending'), action: { kind: 'remove' },
|
|
})).rejects.toBe(error)
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
}
|
|
})
|
|
|
|
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: [{
|
|
// @ts-expect-error -- remote edit payloads can carry unsupported image blocks.
|
|
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(await 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(await 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(await 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(await 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: 'test' },
|
|
})
|
|
inbox.append('next-turn', queued)
|
|
inbox.append('next-step', context)
|
|
|
|
expect(await 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(await controller.updateQueue({
|
|
sessionId: agent.id, itemId: editedContext.id, action: { kind: 'remove' },
|
|
})).toEqual({ accepted: true })
|
|
expect(await 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(await 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()
|
|
})
|
|
})
|