One list call now materializes at most maxEntries child rows (config, default 1000 - GitHub's web-UI directory-listing bound). Candidates sort before probing so a cut level keeps the name-sorted head and symlink probing stops with the bound, and DirectoryListing carries a required truncated flag on the seam and the wire so clients can state incompleteness instead of silently missing tail entries.
404 lines
23 KiB
TypeScript
404 lines
23 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { RpcId, transportError } from '../src/api/rpc.ts'
|
|
import {
|
|
clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema,
|
|
rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema,
|
|
} from '../src/api/rpc.schema.ts'
|
|
import { z } from 'zod'
|
|
import {
|
|
contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema,
|
|
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
|
|
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
|
|
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
|
|
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
|
|
} from '../src/api/sessions.schema.ts'
|
|
import {
|
|
hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema,
|
|
hostDescribeRequestSchema, hostDescribeValueSchema,
|
|
hostListDirectoryRequestSchema, hostListDirectoryValueSchema,
|
|
} from '../src/api/host.schema.ts'
|
|
import {
|
|
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
|
|
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
|
|
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
|
|
workspaceListRequestSchema, workspaceListValueSchema,
|
|
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
|
|
} from '../src/api/workspace.schema.ts'
|
|
import {
|
|
commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema,
|
|
commandListRequestSchema, commandListValueSchema,
|
|
} from '../src/api/commands.schema.ts'
|
|
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
|
|
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
|
|
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
|
|
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
|
|
|
|
describe('RpcId', () => {
|
|
it('brands a raw string at zero runtime cost', () => {
|
|
expect(RpcId('abc')).toBe('abc')
|
|
expect(rpcIdSchema.parse('abc')).toBe('abc')
|
|
// No min-length: the id is an opaque echo token (see rpcIdSchema's contract).
|
|
expect(rpcIdSchema.parse('')).toBe('')
|
|
expect(() => rpcIdSchema.parse(42)).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('transportError', () => {
|
|
it('folds Error and non-Error throws into the internal error branch', () => {
|
|
expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } })
|
|
expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } })
|
|
})
|
|
})
|
|
|
|
describe('rpcErrorSchema', () => {
|
|
it('accepts every code branch with its required details', () => {
|
|
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
|
|
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
|
|
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
|
|
expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b' } }).code).toBe('session-conflict')
|
|
expect(rpcErrorSchema.parse({ code: 'workspace-attach-failed', message: 'm', details: { sessionId: 's', workspaceId: 'w' } }).code).toBe('workspace-attach-failed')
|
|
expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found')
|
|
expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path')
|
|
expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict')
|
|
expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid')
|
|
expect(rpcErrorSchema.parse({
|
|
code: 'model-unavailable',
|
|
message: 'm',
|
|
details: { provider: 'p', model: 'm' },
|
|
}).code).toBe('model-unavailable')
|
|
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
|
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
|
})
|
|
|
|
it('rejects a known code with missing details', () => {
|
|
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
|
|
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('rpcResultSchema', () => {
|
|
it('accepts both result branches and rejects hybrids', () => {
|
|
const schema = rpcResultSchema(z.object({ n: z.number() }))
|
|
expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } })
|
|
const err = schema.parse({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
|
|
expect(err).toMatchObject({ ok: false })
|
|
expect(() => schema.parse({ ok: true, error: {} })).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('wire full-form schemas', () => {
|
|
it('parses the four quadrants and the union discriminates on type', () => {
|
|
const cq = { type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} }
|
|
const sr = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } }
|
|
const rq = { type: 'server-request', rpcId: 'r2', method: 'session/event', payload: { a: 1 } }
|
|
const cr = { type: 'client-response', rpcId: 'r2', result: { ok: true, value: null } }
|
|
expect(clientRequestSchema.parse(cq).method).toBe('session.list')
|
|
expect(serverResponseSchema.parse(sr).rpcId).toBe('r1')
|
|
expect(serverRequestSchema.parse(rq).method).toBe('session/event')
|
|
expect(clientResponseSchema.parse(cr).rpcId).toBe('r2')
|
|
for (const message of [cq, sr, rq, cr]) expect(rpcMessageSchema.parse(message)).toBeTruthy()
|
|
expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow()
|
|
})
|
|
|
|
it('rejects a quadrant missing its members', () => {
|
|
expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow()
|
|
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } })).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('rpcReceiptSchema', () => {
|
|
it('accepts both receipt branches with the closed reason set', () => {
|
|
expect(rpcReceiptSchema.parse({ accepted: true })).toEqual({ accepted: true })
|
|
expect(rpcReceiptSchema.parse({ accepted: false, reason: 'not-pending' })).toEqual({ accepted: false, reason: 'not-pending' })
|
|
expect(rpcReceiptSchema.parse({ accepted: false, reason: 'bad-response' })).toEqual({ accepted: false, reason: 'bad-response' })
|
|
expect(() => rpcReceiptSchema.parse({ accepted: false, reason: 'other' })).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('sessions domain schemas', () => {
|
|
it('validates ids, summaries, and the event passthrough envelope', () => {
|
|
expect(sessionIdSchema.parse('s1')).toBe('s1')
|
|
expect(() => sessionIdSchema.parse('')).toThrow()
|
|
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false, blank: true })).toMatchObject({ sessionId: 's1', blank: true })
|
|
expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x')
|
|
// blank is mandatory: a summary without it fails the parse.
|
|
expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow()
|
|
const event = sessionEventSchema.parse({
|
|
type: 'user/message',
|
|
seq: 0,
|
|
time: 1,
|
|
data: { any: true },
|
|
})
|
|
expect(event).toMatchObject({ type: 'user/message' })
|
|
expect(() => sessionEventSchema.parse({
|
|
type: 'user/message',
|
|
seq: -1,
|
|
time: 1,
|
|
data: {},
|
|
})).toThrow()
|
|
})
|
|
|
|
it('validates the per-method request/value pairs', () => {
|
|
expect(sessionListRequestSchema.parse({})).toEqual({})
|
|
expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c')
|
|
expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([])
|
|
expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
|
|
// The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
|
|
expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')
|
|
expect(() => sessionCreateRequestSchema.parse({ workspaceId: 'w1', cwd: '/w' })).toThrow(/not both/)
|
|
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
|
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
|
|
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
|
|
expect(sessionHistoryValueSchema.parse({
|
|
events: [],
|
|
hasMore: false,
|
|
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
|
}).hasMore).toBe(false)
|
|
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
|
expect(sessionModelsValueSchema.parse({
|
|
current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
|
groups: [{
|
|
id: 'deepseek',
|
|
name: 'DeepSeek',
|
|
models: [{
|
|
id: 'deepseek-v4-flash',
|
|
name: 'DeepSeek V4 Flash',
|
|
description: 'fast',
|
|
unlisted: true,
|
|
reasoning: {
|
|
efforts: [
|
|
{ id: 'off', name: 'Off' },
|
|
{ id: 'max', name: 'Max', description: 'Largest budget' },
|
|
],
|
|
defaultEffort: 'off',
|
|
},
|
|
}],
|
|
}],
|
|
failures: [{ id: 'broken', name: 'Broken', message: 'offline' }],
|
|
}).groups[0]?.models[0]?.id).toBe('deepseek-v4-flash')
|
|
expect(sessionSelectModelRequestSchema.parse({
|
|
sessionId: 's1',
|
|
provider: 'deepseek',
|
|
model: 'deepseek-v4-pro',
|
|
reasoningEffort: 'max',
|
|
}).reasoningEffort).toBe('max')
|
|
expect(sessionSelectModelValueSchema.parse({
|
|
selected: { provider: 'deepseek', model: 'deepseek-v4-pro', reasoningEffort: 'max' },
|
|
}).selected.reasoningEffort).toBe('max')
|
|
expect(() => sessionSelectModelRequestSchema.parse({
|
|
sessionId: 's1',
|
|
provider: '',
|
|
model: 'm',
|
|
})).toThrow()
|
|
expect(() => sessionSelectModelRequestSchema.parse({
|
|
sessionId: 's1',
|
|
provider: 'deepseek',
|
|
model: 'm',
|
|
reasoningEffort: '',
|
|
})).toThrow()
|
|
expect(() => sessionModelsValueSchema.parse({
|
|
current: { provider: 'deepseek', model: 'm' },
|
|
groups: [{
|
|
id: 'deepseek',
|
|
name: 'DeepSeek',
|
|
models: [{ id: 'm', name: 'M', reasoning: { efforts: [] } }],
|
|
}],
|
|
failures: [],
|
|
})).toThrow()
|
|
const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
|
|
expect(prompt.mode).toBe('queue')
|
|
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
|
|
expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true)
|
|
expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
|
expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)
|
|
expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 })
|
|
})
|
|
})
|
|
|
|
describe('host domain schemas', () => {
|
|
it('validates describe request/value', () => {
|
|
expect(hostDescribeRequestSchema.parse({})).toEqual({})
|
|
const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 })
|
|
expect(value.attachedSessions).toBe(2)
|
|
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
|
|
})
|
|
|
|
it('validates the browse listing/creation payloads', () => {
|
|
expect(hostListDirectoryRequestSchema.parse({})).toEqual({})
|
|
expect(hostListDirectoryRequestSchema.parse({ path: '/x' })).toEqual({ path: '/x' })
|
|
const listing = hostListDirectoryValueSchema.parse({
|
|
path: '/home/u/p',
|
|
home: '/home/u',
|
|
crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'p', path: '/home/u/p', hidden: false }],
|
|
entries: [{ name: '.dot', path: '/home/u/p/.dot', hidden: true }],
|
|
truncated: false,
|
|
})
|
|
expect(listing.entries[0]?.hidden).toBe(true)
|
|
// The flag is part of the wire value, not an optional decoration.
|
|
expect(() => hostListDirectoryValueSchema.parse({ path: '/x', home: '/x', crumbs: [], entries: [] })).toThrow()
|
|
expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' })
|
|
for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
|
|
expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow()
|
|
}
|
|
expect(hostCreateDirectoryValueSchema.parse({ path: '/x/new' })).toEqual({ path: '/x/new' })
|
|
})
|
|
})
|
|
|
|
describe('workspace domain schemas', () => {
|
|
const view = {
|
|
workspaceId: 'w1', path: '/p', title: 'p', sessionIds: ['s1'],
|
|
createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z',
|
|
}
|
|
|
|
it('validates ids, the view row, and list request/value', () => {
|
|
expect(workspaceIdSchema.parse('w1')).toBe('w1')
|
|
expect(() => workspaceIdSchema.parse('')).toThrow()
|
|
expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1'])
|
|
expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow()
|
|
expect(workspaceListRequestSchema.parse({})).toEqual({})
|
|
expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1)
|
|
})
|
|
|
|
it('create requires exactly one of path/name (both refine arms)', () => {
|
|
expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p')
|
|
expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n')
|
|
expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/)
|
|
expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/)
|
|
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
|
|
})
|
|
|
|
it('rename requires a non-blank title (both refine arms)', () => {
|
|
expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new')
|
|
expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/)
|
|
expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
|
})
|
|
|
|
it('validates workspace deletion payload and receipt', () => {
|
|
expect(workspaceDeleteRequestSchema.parse({ workspaceId: 'w1' }).workspaceId).toBe('w1')
|
|
expect(() => workspaceDeleteRequestSchema.parse({})).toThrow()
|
|
expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true })
|
|
expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow()
|
|
})
|
|
|
|
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
|
|
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
|
|
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
|
|
expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow()
|
|
expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
|
|
})
|
|
})
|
|
|
|
describe('commands domain schemas', () => {
|
|
it('validates the catalog request/value pair', () => {
|
|
expect(commandListRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
|
// The wire is session-addressed only: a sessionId-less payload fails.
|
|
expect(() => commandListRequestSchema.parse({})).toThrow()
|
|
expect(commandListValueSchema.parse({ commands: [] }).commands).toEqual([])
|
|
const value = commandListValueSchema.parse({ commands: [
|
|
{ name: 'plan', description: 'Toggle plan mode' },
|
|
{ name: 'goal', description: 'Set the goal', input: { hint: '<goal>' } },
|
|
] })
|
|
expect(value.commands[1]?.input?.hint).toBe('<goal>')
|
|
expect(commandDescriptorSchema.parse({ name: 'x', description: 'd' }).input).toBeUndefined()
|
|
expect(() => commandDescriptorSchema.parse({ name: '', description: 'd' })).toThrow()
|
|
expect(() => commandDescriptorSchema.parse({ name: 'x', description: 'd', input: {} })).toThrow()
|
|
})
|
|
|
|
it('validates the execute request/value pair with both matched branches', () => {
|
|
expect(commandExecuteRequestSchema.parse({ sessionId: 's1', line: '/plan off' }).line).toBe('/plan off')
|
|
// Both members are mandatory: dropping either fails the parse.
|
|
expect(() => commandExecuteRequestSchema.parse({ line: '/compact' })).toThrow()
|
|
expect(() => commandExecuteRequestSchema.parse({ sessionId: 's1' })).toThrow()
|
|
expect(commandExecuteValueSchema.parse({ matched: false })).toEqual({ matched: false })
|
|
// Pure admission: matched plus the optional lifecycle pairing id
|
|
// (outcomes ride the logged lifecycle events, never this response).
|
|
expect(commandExecuteValueSchema.parse({ matched: true, commandId: 'cmd-1' }))
|
|
.toEqual({ matched: true, commandId: 'cmd-1' })
|
|
expect(commandExecuteValueSchema.parse({ matched: true })).toEqual({ matched: true })
|
|
expect(() => commandExecuteValueSchema.parse({ matched: true, commandId: '' })).toThrow()
|
|
expect(() => commandExecuteValueSchema.parse({})).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('skills domain schemas', () => {
|
|
it('validates the list request/value pair', () => {
|
|
expect(skillListRequestSchema.parse({ sessionId: 's1' })).toEqual({ sessionId: 's1' })
|
|
// The wire is session-addressed only: a sessionId-less payload fails.
|
|
expect(() => skillListRequestSchema.parse({})).toThrow()
|
|
expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([])
|
|
const value = skillListValueSchema.parse({ skills: [
|
|
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' },
|
|
{ name: 'bare', description: 'No guidance' },
|
|
] })
|
|
expect(value.skills[0]?.whenToUse).toBe('when committing')
|
|
expect(value.skills[1]?.whenToUse).toBeUndefined()
|
|
expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('events frame schemas', () => {
|
|
it('accepts every mux frame branch', () => {
|
|
const frames = [
|
|
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
|
|
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
|
|
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
|
|
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
|
|
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
|
|
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
|
|
{ type: 'session/queued', sessionId: 's', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, steering: false },
|
|
{ type: 'session/queued', sessionId: 's', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, steering: true },
|
|
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
|
|
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
|
]
|
|
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
|
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
|
|
for (const invalid of [
|
|
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
|
|
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },
|
|
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },
|
|
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
|
|
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
|
|
})
|
|
|
|
it('rejects an empty question batch (ask() guarantees at least one, so an empty frame is host breakage)', () => {
|
|
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
|
|
})
|
|
|
|
it('rejects a queued frame missing its members', () => {
|
|
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: 'x', steering: false })).toThrow()
|
|
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } } })).toThrow()
|
|
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: {} }, steering: false })).toThrow()
|
|
})
|
|
|
|
it('accepts every host frame branch', () => {
|
|
const frames = [
|
|
{ type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p' },
|
|
{ type: 'host/session-added', sessionId: 's', blank: true },
|
|
{ type: 'host/session-removed', sessionId: 's' },
|
|
{ type: 'host/session-status', sessionId: 's', running: true },
|
|
{ type: 'host/agent-error', sessionId: 's', message: 'boom' },
|
|
{ type: 'host/workspace-changed', workspace: {
|
|
workspaceId: 'w', path: '/w', title: 'w', sessionIds: [],
|
|
createdAt: '0', updatedAt: '0',
|
|
} },
|
|
{ type: 'host/workspace-removed', workspaceId: 'w' },
|
|
{ type: 'host/commands-changed' },
|
|
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
|
]
|
|
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
|
})
|
|
})
|
|
|
|
describe('respond payload schemas', () => {
|
|
it('validates approval and question answer payloads', () => {
|
|
expect(approvalRequestIdSchema.parse('a1')).toBe('a1')
|
|
const approval = approvalResponsePayloadSchema.parse({ sessionId: 's', approvalId: 'a', outcome: 'rejected' })
|
|
expect(approval.outcome).toBe('rejected')
|
|
expect(() => approvalResponsePayloadSchema.parse({ sessionId: 's', approvalId: 'a', outcome: 'cancelled' })).toThrow()
|
|
const answer = askUserQuestionAnswerSchema.parse({ answers: [{ id: 'q', selected: ['x'], custom: 'c' }] })
|
|
expect(answer.answers[0]?.selected).toEqual(['x'])
|
|
const payload = questionResponsePayloadSchema.parse({ sessionId: 's', answer: { answers: [] } })
|
|
expect(payload.sessionId).toBe('s')
|
|
})
|
|
})
|