Merge master (slash/input/session architecture) into web-session-model-selector
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
HostFrame, IApiClient, ModelTarget, MuxFrame, RpcRequest, RpcResponse, SessionId, SessionModels,
|
||||
CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
@@ -44,21 +45,9 @@ export class FakeApiClient implements IApiClient {
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
() => Promise.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
|
||||
}))
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek', model: 'deepseek-chat' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
}))
|
||||
onSelectModel: (payload: ModelTarget & { sessionId: SessionId })
|
||||
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
@@ -75,9 +64,6 @@ export class FakeApiClient implements IApiClient {
|
||||
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
|
||||
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
@@ -86,6 +72,38 @@ export class FakeApiClient implements IApiClient {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
}
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))),
|
||||
create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
created: true,
|
||||
}))),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
}
|
||||
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
// wire shapes so cases can program catalogs and skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
suppressStreamOpen = false
|
||||
|
||||
|
||||
92
packages/client/connection/tests/fixture-commands.spec.ts
Normal file
92
packages/client/connection/tests/fixture-commands.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Fixture commands/skills domains: contract-shape conformance for the two
|
||||
* domains added to ApiProxy — rpcId echo, session-addressed catalogs, execute
|
||||
* parse/dispatch, skill.list session resolution, and the FixtureApiClient
|
||||
* dispatch rows.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
import type { RpcRequest } from '../src/client/api.ts'
|
||||
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
let reqCount = 0
|
||||
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${reqCount++}`), payload })
|
||||
const signal = new AbortController().signal
|
||||
|
||||
describe('createFixtureApi commands/skills', () => {
|
||||
it('serves the addressed session catalog with rpcId echo', async () => {
|
||||
const api = createFixtureApi()
|
||||
const request = req({ sessionId: sid('fx-alpha') })
|
||||
const response = await api.commands.list(request)
|
||||
expect(response.rpcId).toBe(request.rpcId)
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
const commands = response.result.value.commands
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture'])
|
||||
// input hint rides only the commands declaring it.
|
||||
const echo = commands.find(c => c.name === 'echo')
|
||||
expect(echo?.input?.hint).toBeTruthy()
|
||||
expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a catalog request for an unknown session', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.commands.list(req({ sessionId: sid('fx-nope') }))
|
||||
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('executes a known command line and reports matched with a result', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
|
||||
if (!response.result.ok) throw new Error('execute failed')
|
||||
expect(response.result.value.matched).toBe(true)
|
||||
expect(response.result.value.result).toEqual({ kind: 'success', text: 'hello world' })
|
||||
})
|
||||
|
||||
it('addresses execute to the session (result text carries the id)', async () => {
|
||||
const api = createFixtureApi()
|
||||
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
|
||||
if (!hit.result.ok) throw new Error('execute failed')
|
||||
expect(hit.result.value.matched).toBe(true)
|
||||
expect(hit.result.value.result?.text).toContain('fx-alpha')
|
||||
|
||||
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal)
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('falls to matched:false on unknown names and non-command lines', async () => {
|
||||
const api = createFixtureApi()
|
||||
for (const line of ['/nope', 'plain text', '/']) {
|
||||
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal)
|
||||
if (!response.result.ok) throw new Error('execute failed')
|
||||
expect(response.result.value.matched).toBe(false)
|
||||
expect(response.result.value.result).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the skill catalog for the addressed session and rejects unknown sessions', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.skills.list(req({ sessionId: sid('fx-alpha') }))
|
||||
if (!response.result.ok) throw new Error('skill list failed')
|
||||
expect(response.result.value.skills[0]?.name).toBe('fixture-demo')
|
||||
|
||||
const missingSession = await api.skills.list(req({ sessionId: sid('fx-nope') }))
|
||||
expect(missingSession.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('FixtureApiClient command/skill dispatch', () => {
|
||||
it('routes the three method keys through the in-memory dispatch table', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
const list = await client.commands.list({ sessionId: sid('fx-alpha') })
|
||||
if (!list.result.ok) throw new Error('command.list failed')
|
||||
expect(list.result.value.commands.length).toBeGreaterThan(0)
|
||||
const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' })
|
||||
if (!executed.result.ok) throw new Error('command.execute failed')
|
||||
expect(executed.result.value.matched).toBe(true)
|
||||
const skills = await client.skills.list({ sessionId: sid('fx-alpha') })
|
||||
if (!skills.result.ok) throw new Error('skill.list failed')
|
||||
expect(skills.result.value.skills.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
* the hand-written fixture/host parallel implementations.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '../src/client/api.ts'
|
||||
import type { SessionId, WorkspaceId } from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts'
|
||||
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
|
||||
@@ -123,7 +123,7 @@ describe('createFixtureApi', () => {
|
||||
await consuming
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const createdId = created.result.value.sessionId
|
||||
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId }])
|
||||
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture' }])
|
||||
const list = await api.sessions.list(req({}))
|
||||
if (!list.result.ok) throw new Error('list failed')
|
||||
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
|
||||
@@ -295,6 +295,267 @@ describe('createFixtureApi', () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.host.describe(req({}))
|
||||
expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
|
||||
const empty = await createFixtureApi({ empty: true }).host.describe(req({}))
|
||||
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
|
||||
})
|
||||
|
||||
it('workspace.list serves the resident account and create reuses on path collision', async () => {
|
||||
const api = createFixtureApi()
|
||||
const listed = await api.workspace.list(req({}))
|
||||
if (!listed.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.items).toEqual([expect.objectContaining({
|
||||
workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture',
|
||||
sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'],
|
||||
})])
|
||||
// path collision → the existing entity comes back, created:false, no frame.
|
||||
const reused = await api.workspace.create(req({ path: '/tmp/fixture' }))
|
||||
if (!reused.result.ok) throw new Error('reuse failed')
|
||||
expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } })
|
||||
})
|
||||
|
||||
it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const created = await api.workspace.create(req({ name: 'nova' }))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
expect(created.result.value.created).toBe(true)
|
||||
expect(created.result.value.workspace).toMatchObject({
|
||||
path: '/tmp/fixture-workspaces/nova', title: 'nova', sessionIds: [],
|
||||
})
|
||||
await consuming
|
||||
expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }])
|
||||
// path spelling falls back to the basename when no title/name rides along.
|
||||
const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' }))
|
||||
if (!pathOnly.result.ok) throw new Error('pathOnly failed')
|
||||
expect(pathOnly.result.value.workspace.title).toBe('base')
|
||||
// Degenerate spellings reach the impl unfiltered (the fixture carrier has
|
||||
// no schema gate): both-absent falls back to the bucket dir, and a
|
||||
// basename-less path serves as its own title.
|
||||
const bare = await api.workspace.create(req({}))
|
||||
if (!bare.result.ok) throw new Error('bare failed')
|
||||
expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' })
|
||||
const rootPath = await api.workspace.create(req({ path: '/' }))
|
||||
if (!rootPath.result.ok) throw new Error('rootPath failed')
|
||||
expect(rootPath.result.value.workspace.title).toBe('/')
|
||||
})
|
||||
|
||||
it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
if (seen.length >= 2) abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const wsid = 'fx-ws-fixture' as WorkspaceId
|
||||
const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
|
||||
|
||||
await api.workspace.create(req({ name: 'occupied' }))
|
||||
const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' }))
|
||||
expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } })
|
||||
|
||||
const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' }))
|
||||
if (!noop.result.ok) throw new Error('no-op rename failed')
|
||||
expect(noop.result.value.workspace.title).toBe('fixture')
|
||||
|
||||
const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' }))
|
||||
if (!renamed.result.ok) throw new Error('rename failed')
|
||||
expect(renamed.result.value.workspace.title).toBe('renamed')
|
||||
await consuming
|
||||
// Only the create and the effective rename emit frames; the no-op stays silent.
|
||||
expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed'])
|
||||
})
|
||||
|
||||
it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => {
|
||||
const api = createFixtureApi()
|
||||
const wsid = 'fx-ws-fixture' as WorkspaceId
|
||||
const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
|
||||
const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') }))
|
||||
expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } })
|
||||
const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') }))
|
||||
expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } })
|
||||
|
||||
const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') }))
|
||||
if (!moved.result.ok) throw new Error('move failed')
|
||||
expect(moved.result.value.workspace.sessionIds).toEqual(['fx-alpha', 'fx-gamma', 'fx-beta'])
|
||||
const appended = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') }))
|
||||
if (!appended.result.ok) throw new Error('append failed')
|
||||
expect(appended.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha'])
|
||||
const before = appended.result.value.workspace.updatedAt
|
||||
const noop = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') }))
|
||||
if (!noop.result.ok) throw new Error('no-op move failed')
|
||||
expect(noop.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha'])
|
||||
expect(noop.result.value.workspace.updatedAt).toBe(before)
|
||||
})
|
||||
|
||||
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
if (seen.length >= 2) abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
|
||||
const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const id = created.result.value.sessionId
|
||||
await consuming
|
||||
// The session lands with the workspace's path as cwd, and the account
|
||||
// write pushes the fresh workspace snapshot after session-added.
|
||||
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture' })
|
||||
expect(seen[1]).toMatchObject({
|
||||
type: 'host/workspace-changed',
|
||||
workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('supports an empty baseline, preallocated ids, workspace-first frames, and idempotent retry', async () => {
|
||||
const api = createFixtureApi({ empty: true, createFrameOrder: 'workspace-first' })
|
||||
const initialSessions = await api.sessions.list(req({}))
|
||||
const initialWorkspaces = await api.workspace.list(req({}))
|
||||
expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } })
|
||||
expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } })
|
||||
|
||||
const made = await api.workspace.create(req({ name: 'nova' }))
|
||||
if (!made.result.ok) throw new Error('workspace create failed')
|
||||
const abort = new AbortController()
|
||||
const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const preallocated = sid('fx-preallocated')
|
||||
const created = await api.sessions.create(req({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
sessionId: preallocated,
|
||||
}))
|
||||
expect(created.result).toEqual({ ok: true, value: { sessionId: preallocated } })
|
||||
const frames = await framesPromise
|
||||
expect(frames[0]).toMatchObject({
|
||||
type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] },
|
||||
})
|
||||
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, blank: true, cwd: made.result.value.workspace.path })
|
||||
|
||||
const retried = await api.sessions.create(req({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
sessionId: preallocated,
|
||||
}))
|
||||
expect(retried.result).toEqual({ ok: true, value: { sessionId: preallocated } })
|
||||
const listed = await api.sessions.list(req({}))
|
||||
if (!listed.result.ok) throw new Error('session list failed')
|
||||
expect(listed.result.value.items.filter(item => item.sessionId === preallocated)).toHaveLength(1)
|
||||
|
||||
const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' }))
|
||||
expect(conflict.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'session-conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches an existing ungrouped Session to a matching Workspace', async () => {
|
||||
const api = createFixtureApi()
|
||||
const sessionId = sid('fx-existing-ungrouped')
|
||||
await expect(api.sessions.create(req({ sessionId, cwd: '/tmp/fixture' }))).resolves.toMatchObject({
|
||||
result: { ok: true, value: { sessionId } },
|
||||
})
|
||||
|
||||
await expect(api.sessions.create(req({
|
||||
sessionId,
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
|
||||
|
||||
const workspaces = await api.workspace.list(req({}))
|
||||
if (!workspaces.result.ok) throw new Error('workspace list failed')
|
||||
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
|
||||
})
|
||||
|
||||
it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => {
|
||||
const api = createFixtureApi()
|
||||
const listed = await api.sessions.list(req({}))
|
||||
if (!listed.result.ok) throw new Error('session list failed')
|
||||
const existing = listed.result.value.items.find(item => item.sessionId === sid('fx-alpha'))
|
||||
if (existing === undefined) throw new Error('fixture Session missing')
|
||||
delete existing.cwd
|
||||
|
||||
const conflict = await api.sessions.create(req({ sessionId: existing.sessionId }))
|
||||
expect(conflict.result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'session-conflict',
|
||||
message: `session ${existing.sessionId} already uses no cwd`,
|
||||
details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes an ungrouped Session when Workspace attachment fails', async () => {
|
||||
const api = createFixtureApi({ failWorkspaceAttach: true })
|
||||
const sessionId = sid('fx-partial')
|
||||
const created = await api.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
}))
|
||||
expect(created.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } },
|
||||
})
|
||||
const listed = await api.sessions.list(req({}))
|
||||
const workspaces = await api.workspace.list(req({}))
|
||||
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
|
||||
expect(workspaces.result.value.items[0]?.sessionIds).not.toContain(sessionId)
|
||||
|
||||
const retried = await api.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
}))
|
||||
expect(retried.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
const afterRetry = await api.sessions.list(req({}))
|
||||
if (!afterRetry.result.ok) throw new Error('list failed')
|
||||
expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reconciles a dropped create response and can reject a prompt before acceptance', async () => {
|
||||
const sessionId = sid('fx-lost-response')
|
||||
const dropped = createFixtureApi({ dropSessionCreateResponse: true })
|
||||
await expect(Promise.resolve().then(() => dropped.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
})))).rejects.toThrow(/dropped session\.create response/)
|
||||
const listed = await dropped.sessions.list(req({}))
|
||||
const workspaces = await dropped.workspace.list(req({}))
|
||||
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.items.some(item => item.sessionId === sessionId)).toBe(true)
|
||||
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
|
||||
await expect(dropped.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
|
||||
|
||||
const rejecting = createFixtureApi({ empty: true, rejectPrompt: true })
|
||||
const real = await rejecting.sessions.create(req({ sessionId: sid('fx-rejected') }))
|
||||
if (!real.result.ok) throw new Error('session create failed')
|
||||
const prompt = await rejecting.sessions.prompt(req({
|
||||
sessionId: real.result.value.sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'keep me' }],
|
||||
}))
|
||||
expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
|
||||
})
|
||||
|
||||
it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => {
|
||||
@@ -347,6 +608,7 @@ describe('createFixtureApi', () => {
|
||||
describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => {
|
||||
@@ -382,6 +644,66 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
|
||||
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
|
||||
expect((await client.host.describe({})).result.ok).toBe(true)
|
||||
expect((await client.workspace.list({})).result.ok).toBe(true)
|
||||
const workspace = await client.workspace.create({ name: 'via-client' })
|
||||
if (!workspace.result.ok) throw new Error('workspace create failed')
|
||||
expect(workspace.result.value.workspace.title).toBe('via-client')
|
||||
const wsid = workspace.result.value.workspace.workspaceId
|
||||
const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' })
|
||||
if (!renamed.result.ok) throw new Error('workspace rename failed')
|
||||
expect(renamed.result.value.workspace.title).toBe('via-client-2')
|
||||
const attached = await client.sessions.create({ workspaceId: wsid })
|
||||
if (!attached.result.ok) throw new Error('attached create failed')
|
||||
const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
|
||||
if (!moved.result.ok) throw new Error('workspace move failed')
|
||||
expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId])
|
||||
})
|
||||
|
||||
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
|
||||
vi.stubGlobal('location', {
|
||||
search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first',
|
||||
})
|
||||
const client = new FixtureApiClient()
|
||||
await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } })
|
||||
const made = await client.workspace.create({ name: 'query-workspace' })
|
||||
if (!made.result.ok) throw new Error('workspace create failed')
|
||||
const abort = new AbortController()
|
||||
const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const sessionId = sid('fx-query-session')
|
||||
const created = await client.sessions.create({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
sessionId,
|
||||
})
|
||||
expect(created.result).toMatchObject({ ok: true, value: { sessionId } })
|
||||
const frames = await framesPromise
|
||||
expect(frames.map(frame => frame.type)).toEqual(['host/workspace-changed', 'host/session-added'])
|
||||
const rejected = await client.sessions.prompt({
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'retain' }],
|
||||
})
|
||||
expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
|
||||
})
|
||||
|
||||
it('maps attach-failure and dropped-response query scenarios', async () => {
|
||||
vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' })
|
||||
const partial = new FixtureApiClient()
|
||||
const partialResult = await partial.sessions.create({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId: sid('fx-query-partial'),
|
||||
})
|
||||
expect(partialResult.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-attach-failed', details: { sessionId: 'fx-query-partial' } },
|
||||
})
|
||||
|
||||
vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' })
|
||||
const dropped = new FixtureApiClient()
|
||||
await expect(dropped.sessions.create({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId: sid('fx-query-dropped'),
|
||||
})).rejects.toThrow(/dropped session\.create response/)
|
||||
})
|
||||
|
||||
it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '../src/index.ts'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, inject } from '../src/index.ts'
|
||||
|
||||
describe('node half', () => {
|
||||
it('apply is a no-op host placeholder', () => {
|
||||
apply(undefined)
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
describe('connection node half', () => {
|
||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
// Structural fake: the plugin only touches register(); the service class
|
||||
// carries private state a literal cannot (and need not) reproduce.
|
||||
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
ctx.provide('httpServer', httpServer as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user