From dcddaa1a6e4b386c49fdbe3a01414ad5bc0c01d5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:11:51 +0800 Subject: [PATCH] refactor(client): replace legacy Host event carriers --- apps/web/tests/agent-preset-authoring.e2e.ts | 9 +- apps/web/tests/agent-preset-selection.e2e.ts | 5 +- apps/web/tests/chat-scroll-contract.e2e.ts | 9 +- apps/web/tests/default-model.e2e.ts | 31 +- apps/web/tests/lifecycle-chrome.e2e.ts | 2 +- apps/web/tests/replay-round-trip.e2e.ts | 2 +- apps/web/tests/seeded-history.e2e.ts | 12 +- apps/web/tests/smoke-real.e2e.ts | 52 +- apps/web/tests/startup-auto-selection.e2e.ts | 8 +- apps/web/tests/steering.e2e.ts | 2 +- apps/web/tests/subagent-conversation.e2e.ts | 8 +- apps/web/tests/subagent-interrupt-ui.e2e.ts | 4 +- apps/web/tests/subagent-interrupt.e2e.ts | 21 +- .../tests/trajectory-virtualization.e2e.ts | 9 +- packages/api/remotes/src/client/index.ts | 40 +- packages/api/remotes/src/index.ts | 170 +- packages/api/remotes/src/remote-events.ts | 38 +- packages/api/remotes/src/types.ts | 2 +- .../remotes/tests/remote-events.host.spec.ts | 216 ++ packages/client/connection/src/api-path.ts | 9 +- .../connection/src/api-request-trust.ts | 11 +- packages/client/connection/src/client/api.ts | 15 +- .../connection/src/client/connection.ts | 151 +- .../client/connection/src/client/fixture.ts | 1768 ++++++----- .../client/connection/src/client/index.ts | 85 +- packages/client/connection/src/client/rpc.ts | 60 +- .../connection/src/client/web-api-client.ts | 85 +- packages/client/connection/src/http-bridge.ts | 8 +- packages/client/connection/src/index.ts | 39 +- packages/client/connection/src/rpc-host.ts | 19 +- packages/client/connection/src/rpc.ts | 48 +- .../connection/src/websocket-downlink.ts | 153 - .../tests/client-apply.client.spec.ts | 296 +- .../tests/connection.client.spec.ts | 148 +- .../connection/tests/fake-api.client.ts | 151 +- .../tests/fixture-commands.client.spec.ts | 28 +- .../connection/tests/fixture.client.spec.ts | 1172 ++++++-- .../connection/tests/node-half.host.spec.ts | 54 +- .../tests/websocket-downlink.host.spec.ts | 308 -- .../client/locale/tests/apply.client.spec.ts | 11 +- .../client/runtime/src/client/agents/scope.ts | 5 +- .../src/client/contract/conversation.ts | 4 +- .../runtime/src/client/contract/session.ts | 12 +- .../runtime/src/client/contract/sessions.ts | 8 +- packages/client/runtime/src/client/index.ts | 94 +- .../src/client/sessions/conversation.ts | 16 +- .../runtime/src/client/sessions/lineage.ts | 4 +- .../runtime/src/client/sessions/manager.ts | 458 ++- .../runtime/src/client/sessions/pending.ts | 58 +- .../src/client/sessions/projection-store.ts | 24 +- .../src/client/sessions/queue-mirror.ts | 33 +- .../runtime/src/client/sessions/remotes.ts | 4 +- .../runtime/src/client/sessions/service.ts | 71 +- .../runtime/src/client/sessions/session.ts | 431 ++- .../runtime/tests/client-apply.client.spec.ts | 137 +- .../conversation-registry.client.spec.ts | 2 +- .../client/runtime/tests/fake-api.client.ts | 429 ++- .../runtime/tests/manager.client.spec.ts | 636 ++-- .../tests/projection-store.client.spec.ts | 63 +- .../runtime/tests/queue-store.client.spec.ts | 134 +- .../runtime/tests/session.client.spec.ts | 303 +- .../tests/sessions-service.client.spec.ts | 43 +- .../runtime/tests/wire-events.client.spec.ts | 135 - .../tests/apply.client.spec.ts | 26 +- .../ui-commands/tests/service.client.spec.ts | 25 +- .../src/client/contract/slots.ts | 2 +- .../src/client/input/contract.ts | 2 +- .../tests/chat-view.client.spec.tsx | 6 +- .../tests/produced-files.client.spec.tsx | 10 +- .../tests/browser-plugin.client.spec.ts | 10 +- .../tests/apply.client.spec.ts | 18 +- .../tests/apply.client.spec.ts | 19 +- .../ui-settings/tests/plugin.client.spec.ts | 12 +- .../tests/browser-plugin.client.spec.ts | 8 +- .../ui-theme/tests/apply.client.spec.ts | 15 +- .../src/client/contract/slots.ts | 4 +- .../tests/plan-review-panel.client.spec.tsx | 23 +- .../user-questions-composer.client.spec.tsx | 39 +- packages/client/web/src/boot.ts | 9 +- packages/client/web/tests/boot.client.spec.ts | 69 + .../tests/plugin.client.spec.ts | 34 +- packages/host/apiproxy/src/api-proxy.ts | 2573 +---------------- .../apiproxy/src/api/agent-presets.schema.ts | 2 +- .../host/apiproxy/src/api/downloads.schema.ts | 5 +- packages/host/apiproxy/src/api/downloads.ts | 5 +- .../host/apiproxy/src/api/events.schema.ts | 93 - packages/host/apiproxy/src/api/events.ts | 155 - packages/host/apiproxy/src/api/goals.ts | 9 +- packages/host/apiproxy/src/api/index.ts | 42 +- packages/host/apiproxy/src/api/llm.schema.ts | 43 +- packages/host/apiproxy/src/api/llm.ts | 8 +- packages/host/apiproxy/src/api/rpc-map.ts | 29 +- packages/host/apiproxy/src/api/rpc.schema.ts | 50 +- packages/host/apiproxy/src/api/rpc.ts | 61 +- .../host/apiproxy/src/api/skills.schema.ts | 2 +- packages/host/apiproxy/src/api/skills.ts | 8 +- .../host/apiproxy/src/api/subagents.schema.ts | 22 +- packages/host/apiproxy/src/api/subagents.ts | 21 +- packages/host/apiproxy/src/fetch/client.ts | 190 +- packages/host/apiproxy/src/fetch/handler.ts | 111 +- packages/host/apiproxy/src/index.ts | 35 +- packages/host/apiproxy/src/invariant.ts | 6 +- .../tests/api-proxy-agent-preset.spec.ts | 308 +- .../apiproxy/tests/api-proxy-config.spec.ts | 120 +- .../apiproxy/tests/api-proxy-host.spec.ts | 205 ++ .../tests/api-proxy-skills-cold.spec.ts | 77 + .../tests/api-proxy-subagents.spec.ts | 157 +- .../apiproxy/tests/client-handler.spec.ts | 416 +-- .../host/apiproxy/tests/fetch-carrier.spec.ts | 379 +-- .../host/apiproxy/tests/rpc-schemas.spec.ts | 411 +-- .../apiproxy/tests/session-export.spec.ts | 19 +- .../test-support/client-runtime/src/remote.ts | 14 +- .../client-runtime/src/sessions.ts | 7 +- .../tests/remote.client.spec.ts | 8 +- 114 files changed, 5541 insertions(+), 8744 deletions(-) create mode 100644 packages/api/remotes/tests/remote-events.host.spec.ts delete mode 100644 packages/client/connection/src/websocket-downlink.ts delete mode 100644 packages/client/connection/tests/websocket-downlink.host.spec.ts delete mode 100644 packages/client/runtime/tests/wire-events.client.spec.ts delete mode 100644 packages/host/apiproxy/src/api/events.schema.ts delete mode 100644 packages/host/apiproxy/src/api/events.ts create mode 100644 packages/host/apiproxy/tests/api-proxy-host.spec.ts create mode 100644 packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 04eb232c7d..d73ec11dc3 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -259,17 +259,18 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await dialog.waitFor({ state: 'detached', timeout: 10_000 }) await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 }) await expect.poll(async () => { - const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { + const response = await fetch(`${scaffold.baseUrl}/api/session/list`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - type: 'client-request', rpcId: 'creator-draft-stage', method: 'session.list', payload: {}, + type: 'client-request', rpcId: 'creator-draft-stage', method: 'session/list', + payload: { args: { _request: {} } }, }), }) const body = await response.json() as { - result: { value?: { sessions: unknown[] } } + result: { value?: { items: unknown[] } } } - return JSON.stringify(body.result.value?.sessions ?? body.result) + return JSON.stringify(body.result.value?.items ?? body.result) }, { timeout: 15_000 }).toContain('"agentPreset":"cordis"') }, 60_000) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 3d7c10abbc..071a7602a5 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -145,11 +145,12 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise * @returns the live session's preset, or undefined before it is listed. */ async function livePreset(baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/api/session.list`, { + const response = await fetch(`${baseUrl}/api/session/list`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {}, + type: 'client-request', rpcId: 'agent-preset-live', method: 'session/list', + payload: { args: { _request: {} } }, }), }) const body = await response.json() as { diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 2e1892f8b6..a1cfd8074b 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -482,12 +482,13 @@ describe('web e2e: long Chat scroll contract', () => { let releaseGate: (() => void) | undefined const gate = new Promise((resolve) => { releaseGate = resolve }) releaseHistory = () => { releaseGate?.() } - await world.page.route('**/api/session.history', async (route) => { + await world.page.route('**/api/session/page', async (route) => { const request = route.request().postDataJSON() as { method?: string - payload?: { beforeSeq?: number } + payload?: { args?: { request?: { beforeSeq?: number } } } } - if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) { + if (!held && request.method === 'session/page' + && request.payload?.args?.request?.beforeSeq !== undefined) { held = true await gate } @@ -524,7 +525,7 @@ describe('web e2e: long Chat scroll contract', () => { await settled await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) - await world.page.unroute('**/api/session.history') + await world.page.unroute('**/api/session/page') let additionalPages = 0 while (additionalPages < 8) { diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 791f8e98c1..d4393bf64b 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -39,22 +39,16 @@ describe('web e2e: the composer model switch is the default for later sessions', /** Create one session and its agent through the same wire face the browser uses. */ const createSession = async (sessionId: string): Promise => { - const response = await scaffold.ctx.apiProxy.sessions.create({ - rpcId: `default-model-create-${sessionId}` as never, - payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd }, + const response = await scaffold.ctx.sessionController.create({ + sessionId: SessionId(sessionId), + cwd: scaffold.workspaceCwd, }) - if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`) - return response.result.value.sessionId + return response.sessionId } /** The route the gateway reports for one session, through the real wire face. */ const currentOf = async (sessionId: string): Promise => { - const response = await scaffold.ctx.apiProxy.sessions.models({ - rpcId: `default-model-${sessionId}` as never, - payload: { sessionId: SessionId(sessionId) }, - }) - if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`) - return response.result.value.current + return (await scaffold.ctx.sessionController.models({ sessionId: SessionId(sessionId) })).current } beforeAll(async () => { @@ -144,15 +138,12 @@ describe('web e2e: the composer model switch is the default for later sessions', // The block is an affordance; the refusal is the Host's. A client that // never disabled anything still cannot start a turn on a dead route. - const refused = await scaffold.ctx.apiProxy.sessions.prompt({ - rpcId: 'default-model-refused' as never, - payload: { - sessionId: SessionId(await createSession('default-model-refusal')), - mode: 'queue' as const, - content: [{ type: 'text' as const, text: 'hi' }], - }, - }) - expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } }) + await expect(scaffold.ctx.sessionController.prompt({ + requestId: 'default-model-refused' as never, + sessionId: SessionId(await createSession('default-model-refusal')), + mode: 'queue', + content: [{ type: 'text', text: 'hi' }], + }, new AbortController().signal)).rejects.toMatchObject({ failure: { code: 'model-unavailable' } }) // The way out stays open. Locking the model seat with everything else // would leave the composer asking for the one thing it prevents. diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 869aac2f14..3a814878da 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -228,7 +228,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) // Selection persisted (dsh.sessions.current) and history replayed: the - // recorded turn re-renders from session.history with zero model calls — + // recorded turn re-renders from a Session Controller page with zero model calls — // the replay cursor was fully consumed before the reload, so any stray // request would fail the scenario loudly at close(). await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index f55cb0e7b5..74e0a6df1d 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -153,7 +153,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think')) // Interaction over the REAL wire-delivered transcript (the fixture-client // tier pins the same gesture against FixtureApiClient; this one runs on - // mux-frame-fed state). Runs after the golden capture so the committed + // follow-stream-fed state). Runs after the golden capture so the committed // aria surface stays the untouched settled state. const think = page.getByRole('button', { name: /^Think/ }).first() expect(await think.getAttribute('aria-expanded')).toBe('false') diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 20b183556b..e593352d6b 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -1,7 +1,7 @@ // Web e2e scenario: seeded history. A recorded session seeded cold through // the REAL persistence API renders purely from the log — the surface nothing -// else covers: sidebar cold listing, the implicit resume/attach inside the -// history RPC, history-page tool views, and the client's log-ordered transcript +// else covers: sidebar cold listing, cold history paging without Agent +// activation, history-page tool views, and the client's log-ordered transcript // events — with ZERO model calls in replay (no replay fixture; a stray stream // fails loud on the open llm seam). The cold session also carries keyless // command-row surfaces: the seeded manual `/compact` lifecycle folds into its @@ -232,12 +232,14 @@ describe('web e2e: seeded history renders through cold resume', () => { // injection stays silent and this block disappears (no titles/todos on // the web), while fixture-level suites stay green. Assert through the // real HTTP wire against the booted real host. - const response = await fetch(`${scaffold.baseUrl}/api/session.history`, { + const response = await fetch(`${scaffold.baseUrl}/api/session/page`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - type: 'client-request', rpcId: 'seeded-projections', method: 'session.history', - payload: { sessionId: SEED_ID }, + type: 'client-request', rpcId: 'seeded-projections', method: 'session/page', + payload: { + args: { request: { address: { kind: 'session', sessionId: SEED_ID } } }, + }, }), }) expect(response.ok).toBe(true) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 3ac6706a6f..60c6814824 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -16,6 +16,7 @@ // sequentially in-file. import type { ChildProcess } from 'node:child_process' import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { createRequire } from 'node:module' @@ -50,22 +51,22 @@ function waitForReadyLine(child: ChildProcess): Promise { }) } -async function rpc(baseUrl: string, method: string, payload: unknown): Promise { - const response = await fetch(`${baseUrl}/api/${method}`, { +async function remoteRpc(baseUrl: string, endpoint: string, args: object): Promise { + const response = await fetch(`${baseUrl}/api/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', - rpcId: `smoke-${method}`, - method, - payload, + rpcId: `smoke-${endpoint}`, + method: endpoint, + payload: { args }, }), }) - if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`) + if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`) const body = await response.json() as { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } - if (!body.result.ok) throw new Error(`${method} failed: ${body.result.error.code}: ${body.result.error.message}`) + if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.code}: ${body.result.error.message}`) return body.result.value } @@ -101,7 +102,9 @@ function hasAssistantMarker(page: HistoryPage, marker: string): boolean { } async function history(baseUrl: string, sessionId: string): Promise { - return rpc(baseUrl, 'session.history', { sessionId, maxMessages: 10 }) + return remoteRpc(baseUrl, 'session/page', { + request: { address: { kind: 'session', sessionId }, maxMessages: 10 }, + }) } async function waitForProviderTitle(baseUrl: string, sessionId: string): Promise { @@ -242,12 +245,13 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) - await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} }) + await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: { + requestId: randomUUID(), sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: 'go' }], - }) + } }) const capturedRequests = await Promise.race([ providerRequests, new Promise((_resolve, reject) => { @@ -354,12 +358,13 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) - await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} }) + await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: { + requestId: randomUUID(), sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: promptMarker }], - }) + } }) let page: HistoryPage | undefined await expect.poll(async () => { page = await history(baseUrl, created.sessionId) @@ -438,12 +443,13 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) - await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} }) + await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: { + requestId: randomUUID(), sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: 'go' }], - }) + } }) const captured = await Promise.race([ providerRequest, new Promise((_resolve, reject) => { @@ -555,10 +561,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke productTitle, { timeout: 15_000 }, ) - await expect.poll(async () => (await rpc<{ items: { sessionId: string }[] }>(baseUrl, 'session.list', {})).items.length, { + await expect.poll(async () => (await remoteRpc<{ items: { sessionId: string }[] }>( + baseUrl, + 'session/list', + { _request: {} }, + )).items.length, { timeout: 15_000, }).toBe(1) - const sessions = await rpc<{ items: { sessionId: string }[] }>(baseUrl, 'session.list', {}) + const sessions = await remoteRpc<{ items: { sessionId: string }[] }>( + baseUrl, + 'session/list', + { _request: {} }, + ) const sessionId = sessions.items[0]?.sessionId if (sessionId === undefined) throw new Error('created Web session was not listed') const durableTitle = await waitForProviderTitle(baseUrl, sessionId) diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index b39e720600..39cfece887 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -5,7 +5,7 @@ // workspace and opens its blank session. `openState` flips to `loading` the // moment `open()` lands; driving `data-phase=settling` on the conversation // root from that flip would hide the composer seat and the header -// (`visibility:hidden`) for the whole `session.history` round-trip — the +// (`visibility:hidden`) for the whole `session.page` round-trip — the // center column blanks and repaints like a full-page refresh on every launch. // // The unit spec pins the phase condition over hand-built stores. What only the @@ -17,7 +17,7 @@ // replacing those nodes. // // The round-trip against a loopback host is far too fast to observe, so this -// scenario HOLDS the `session.history` response open in the browser's network +// scenario HOLDS the `session.page` response open in the browser's network // handler and asserts the visible frame while it is in flight. That wait is // what makes the assertions non-vacuous: without the phase exemption, the held // window is exactly when `settling` would be painted and the composer hidden. @@ -31,8 +31,8 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' -/** Wire path of the history round-trip the conversation root waits out (POST /api/session.history). */ -const HISTORY_ROUTE = '**/api/session.history' +/** Wire path of the history round-trip the conversation root waits out. */ +const HISTORY_ROUTE = '**/api/session/page' /** * The conversation root's own phase attribute. `div` disambiguates it from the diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 43fccd65b5..8305771c4c 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -19,7 +19,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // Two goldens pin the transient Host projection and its durable handoff: the -// mid-turn state renders accepted steering from session/queue while the +// mid-turn state renders accepted steering from the Session control queue while the // question blocks admission, then the settled state renders the same message // from user/message beside the reply that obeys it. const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index eb5b364628..67cf578d76 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -219,7 +219,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, ]) // These two cold fixtures were authored after the page's initial - // session.list and intentionally emitted no session-added frame. Reload + // session.list and intentionally emitted no api-session/added event. Reload // to exercise the restart baseline that discovers their full lineage. const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) @@ -352,7 +352,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) }) - it('continues through FIFO follow-up admission and receives the child mux events', async () => { + it('continues through FIFO follow-up admission and receives the child follow events', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup')) const ended = new Promise((resolveEnded, reject) => { const timer = setTimeout(() => { @@ -451,7 +451,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await page.getByRole('textbox', { name: 'Message the agent' }).waitFor() const forkResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/session.fork') + new URL(response.url()).pathname === '/api/session/fork') await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() const forkReceipt = await (await forkResponse).json() as { result: { ok: boolean } } expect(forkReceipt.result).toMatchObject({ ok: true }) @@ -479,7 +479,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(scaffold.ctx.agents.get(childId)).toBeUndefined() const forkResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/session.fork') + new URL(response.url()).pathname === '/api/session/fork') await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() const forkReceipt = await (await forkResponse).json() as { result: { ok: true; value: { sessionId: string } } | { ok: false } diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 810fe4bcf1..592d55abe6 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -232,7 +232,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co expect(((await (await interruptResponse).json()) as { result: { ok: boolean; value?: { accepted: boolean } } }).result).toMatchObject({ ok: true, value: { accepted: true } }) - expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([]) + expect(apiCalls.filter(path => path === '/api/session/cancel')).toEqual([]) await aborted await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle') @@ -280,7 +280,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co result: { ok: boolean; value?: { accepted: boolean } } }).result).toMatchObject({ ok: true, value: { accepted: true } }) // The addressed child stops through its own RPC, never the generic one. - expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([]) + expect(apiCalls.filter(path => path === '/api/session/cancel')).toEqual([]) await aborted // Parked: the Activation stays resident and idle with the retained diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 757c695408..b3b9a53037 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -22,7 +22,7 @@ const WAKING = 'And add one concrete example.' type RpcResult = { ok: true; value: T } | { ok: false; error: { code: string; message: string } } -/** POST one unary RPC through the real HTTP carrier and unwrap its result. */ +/** POST one API Proxy unary RPC through the real HTTP carrier and unwrap its result. */ async function rpc(baseUrl: string, method: string, payload: unknown): Promise> { const response = await fetch(`${baseUrl}/api/${method}`, { method: 'POST', @@ -38,6 +38,23 @@ async function rpc(baseUrl: string, method: string, payload: unknown): Promis return (await response.json() as { result: RpcResult }).result } +/** POST one generated Session Remote unary through the API Gateway carrier. */ +async function sessionRemote(baseUrl: string, method: string, request: unknown): Promise> { + const endpoint = `session/${method}` + const response = await fetch(`${baseUrl}/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: `interrupt-e2e-${endpoint}-${randomUUID()}`, + method: endpoint, + payload: { args: { request } }, + }), + }) + if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`) + return (await response.json() as { result: RpcResult }).result +} + /** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */ async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs @@ -91,7 +108,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co }) // A live parent Agent through the real API; no workspace or browser. - const created = await rpc<{ sessionId: string }>(scaffold.baseUrl, 'session.create', { + const created = await sessionRemote<{ sessionId: string }>(scaffold.baseUrl, 'create', { cwd: scaffold.workspaceCwd, }) if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`) diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts index 16880295fd..d3f88d3a7f 100644 --- a/apps/web/tests/trajectory-virtualization.e2e.ts +++ b/apps/web/tests/trajectory-virtualization.e2e.ts @@ -218,12 +218,13 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => { let finishHeldRequest: () => void = () => {} const gate = new Promise((resolve) => { releaseHistory = resolve }) const heldRequestFinished = new Promise((resolve) => { finishHeldRequest = resolve }) - await page.route('**/api/session.history', async (route) => { + await page.route('**/api/session/page', async (route) => { const request = route.request().postDataJSON() as { method?: string - payload?: { beforeSeq?: number } + payload?: { args?: { request?: { beforeSeq?: number } } } } - if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) { + if (!held && request.method === 'session/page' + && request.payload?.args?.request?.beforeSeq !== undefined) { held = true await gate try { @@ -340,7 +341,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => { } finally { releaseHistory() if (held) await heldRequestFinished - await page.unroute('**/api/session.history') + await page.unroute('**/api/session/page') } }, 180_000) }) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 6a5164f160..db78433d82 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -8,9 +8,11 @@ import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' -import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' +import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' +import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' -export type { TypertClientRemote as ClientRemote } from '@deepseek-ai/dsh-typert-protocol' +export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-file-reference/remote' @@ -18,6 +20,11 @@ export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' +export type {} from '@deepseek-ai/dsh-api-session-controller/remote' +export type * from '@deepseek-ai/dsh-api-session-controller/types' +export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' +export type * from '@deepseek-ai/dsh-api-workspace-controller/types' +export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -30,6 +37,9 @@ export type {} from '@deepseek-ai/dsh-credentials/types' export type {} from '@deepseek-ai/dsh-llm/types' export type {} from '@deepseek-ai/dsh-agent-presets/types' export type {} from '@deepseek-ai/dsh-settings/types' +export type {} from '@deepseek-ai/dsh-user-approval/types' +export type {} from '@deepseek-ai/dsh-user-questions/types' +export type {} from '@deepseek-ai/dsh-api-session-controller/types' /** * The carrier's Client-facing types, re-exported so a business package names one @@ -37,14 +47,12 @@ export type {} from '@deepseek-ai/dsh-settings/types' * the carrier's runtime values stay behind their own module edge. */ export type { - ClientResponse, ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, - CredentialView, DirectoryListing, DiscoveredModelView, HistoryEntry, HostFrame, IApiClient, + ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, + CredentialView, DirectoryListing, DiscoveredModelView, IApiClient, MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, - MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt, - RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem, - SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, - SubagentAddress, SubagentCatalog, JobView, ToolCallView, ToolEventView, ToolResultView, - WorkspaceId, WorkspaceView, + RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, + SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, + SubagentAddress, SubagentCatalog, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' export type {} from '@deepseek-ai/dsh-api-gateway/client' export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote' @@ -95,10 +103,21 @@ export type { JsonValue } from '@deepseek-ai/dsh-session/types' export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' +/** Failure vocabulary exposed by the assembled Client data layer. */ +export type ClientFailure = + | import('@deepseek-ai/dsh-client-connection/client').RpcError + | import('@deepseek-ai/dsh-api-session-controller/types').SessionError + | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError + +/** Success or failure returned by Client operations spanning both API families. */ +export type ClientResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: ClientFailure } + declare module '@deepseek-ai/cordis' { interface Context { /** Generated Remote namespaces selected by this Client assembly. */ - remote: TypertClientRemote + remote: ClientRemote } } @@ -116,6 +135,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, + sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 6572c11938..4d0256e162 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -1,6 +1,15 @@ /** Host BFF entry and Loader shell for the Remote contribution assembly. */ -import type { TypertForwardableEvent } from '@deepseek-ai/dsh-typert-protocol' +import type { Context } from '@deepseek-ai/cordis' +import type { + TypertRemoteEventDispatch, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, +} from '@deepseek-ai/dsh-api-gateway' +import { carrierKeyOf } from '@deepseek-ai/dsh-scope' +import { isJsonValue } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-session' import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' // The owner packages' client-safe `./types` exports carry the cordis `Events` @@ -13,32 +22,143 @@ import type {} from '@deepseek-ai/dsh-credentials/types' import type {} from '@deepseek-ai/dsh-llm/types' import type {} from '@deepseek-ai/dsh-agent-presets/types' import type {} from '@deepseek-ai/dsh-settings/types' +import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-user-questions' +export type {} from '@deepseek-ai/dsh-api-session-controller/types' -export { - ApiRemoteSessionNotFound, - ApiRemoteSubagentSessionOwnership, - apiRemoteSubagentOwnershipError, - createApiRemoteAgentResolver, - hasApiRemoteSubagentOwner, - inspectApiRemoteSession, -} from './agent-lookup.ts' -export type { - ApiRemoteAgentOptions, - ApiRemoteAgentResult, - ApiRemoteLookupError, -} from './agent-lookup.ts' export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' export type { ApiRemoteForwardedEvent } from './types.ts' -// Shape gate over the allowlist, kept in the Host face because the Host's event -// vocabulary is the authoritative one. It pins three things at compile time: -// every entry NAMES a declared event (the predicate is keyed on `keyof -// Events`), no entry BINDS a Scope (a scoped event's `ThisParameterType` is not -// `unknown`, which is how "must not depend on AgentScope" is stated statically), -// and every entry is ONE-WAY (a waterfall or bail shape returns something other -// than void and is excluded). Widening the array to an event that fails any of -// these fails here, not on the wire. -API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[] +/** Required Host service: the Gateway owns the physical Remote stream mux. */ +export const inject = ['typertGateway'] -/** Host plugin body; the selected contributions mount only in Client environments. */ -export function apply(): void {} +/** Host plugin body registering this application's selected Cordis event source. */ +export function apply(ctx: Context): void { + ctx.effect( + () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx)), + 'api-remotes: forwarded Cordis event source', + ) +} + +/** Create the sole queue and listener set consumed by the registered Gateway. */ +function remoteEventSource(ctx: Context): TypertRemoteEventSource { + return (signal) => { + const queue = new RemoteEventQueue() + const disposers = API_REMOTE_FORWARDED_EVENTS.map(({ event, mode }) => { + if (mode === 'emit') { + return ctx.on(event as never, ((...args: unknown[]) => { + queue.push({ event, args: assertJsonArgs(event, args) }) + }) as never) + } + return ctx.on(event as never, (function ( + this: unknown, + request: object, + next: () => unknown, + ) { + const subject = carrierKeyOf(this) + if (subject === undefined) return next() + const value = Reflect.get(subject, 'ctx') as unknown + if (typeof value !== 'object' || value === null) { + throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} has no live Context`) + } + return forwardWaterfall( + queue, + event, + request, + { value: value as Context, subject }, + next, + ) + }) as never) + }) + return queue.iterate(signal, () => { + for (const dispose of disposers) dispose() + }) + } +} + +/** One pull-driven queue bridging synchronous Cordis listeners to an AsyncIterable. */ +class RemoteEventQueue { + private readonly buffer: TypertRemoteEventDispatch[] = [] + private waiter: (() => void) | undefined + private done = false + + push(frame: TypertRemoteEventDispatch): boolean { + if (this.done) return false + this.buffer.push(frame) + this.waiter?.() + return true + } + + private end(reason: unknown): void { + if (this.done) return + this.done = true + const buffered = this.buffer.splice(0) + for (const dispatch of buffered) { + if ('context' in dispatch) dispatch.reject(reason) + } + this.waiter?.() + } + + async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator { + const abort = (): void => { this.end(remoteEventSourceEndReason(signal)) } + signal.addEventListener('abort', abort, { once: true }) + try { + while (true) { + if (this.done || signal.aborted) return + while (this.buffer.length > 0) yield this.buffer.shift() as TypertRemoteEventDispatch + await new Promise((resolve) => { this.waiter = resolve }) + this.waiter = undefined + } + } finally { + signal.removeEventListener('abort', abort) + this.end(remoteEventSourceEndReason(signal)) + cleanup() + } + } +} + +/** + * Normalize an event-source shutdown for pending Host waterfalls. + * @param signal - source lifetime whose reason wins after cancellation. + * @returns the cancellation reason or an unexpected-end failure. + */ +function remoteEventSourceEndReason(signal: AbortSignal): unknown { + if (signal.aborted) return signal.reason + return new Error('api-remotes: forwarded Remote event source ended') +} + +/** Bridge one Cordis waterfall listener through the Gateway-owned pending event. */ +function forwardWaterfall( + queue: RemoteEventQueue, + event: string, + request: object, + context: TypertRemoteEventInvocation['context'], + next: () => unknown, +): Promise { + const settled = Promise.withResolvers() + const dispatch: TypertRemoteEventInvocation = { + event, + request, + context, + resolve: (outcome: TypertRemoteEventOutcome) => { + if (outcome.kind === 'result') { + settled.resolve(outcome.value) + return + } + void Promise.resolve().then(next).then(settled.resolve, settled.reject) + }, + reject: settled.reject, + } + if (!queue.push(dispatch)) void Promise.resolve().then(next).then(settled.resolve, settled.reject) + return settled.promise +} + +/** Reject an allowlisted event whose runtime arguments are not lossless JSON data. */ +function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] { + for (const [index, arg] of args.entries()) { + if (!isJsonValue(arg)) { + throw new Error(`forwarded host event "${event}" argument ${String(index)} is not lossless JSON data`) + } + } + return args as JsonValue[] +} diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index 949778cbe8..bf98fe536c 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -6,24 +6,26 @@ * type-only. */ +import { SESSION_CONTROLLER_REMOTE_EVENTS } from '@deepseek-ai/dsh-api-session-controller/remote-events' +import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol' + /** - * Host events this application forwards to consumers verbatim: no projection, - * no redaction, no renaming. The wire name is the Host cordis event name and - * the payload is its argument list, so this array is simultaneously the whole - * control point over what a consumer can receive and the legal key set of - * `ctx.remote.$on`. Forwarding one more event is an entry here and nothing - * else. + * Host events this application forwards without renaming. The explicit mode is + * both the Host dispatch strategy and the legal key set of `ctx.remote.$on`. */ export const API_REMOTE_FORWARDED_EVENTS = [ - 'agent-preset/selected', - 'commands/change', - 'credentials/reference-updated', - 'cordis/request-run', - 'cordis/request-run-resolved', - 'cordis/dynamic-package', - 'cordis/dynamic-retract', - 'cordis/inspect-query', - 'cordis/inspect-query-resolved', - 'llm/adapters-updated', - 'settings/document-updated', -] as const + { event: 'agent-preset/selected', mode: 'emit' }, + { event: 'approval/request', mode: 'waterfall' }, + ...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })), + { event: 'commands/change', mode: 'emit' }, + { event: 'credentials/reference-updated', mode: 'emit' }, + { event: 'cordis/request-run', mode: 'emit' }, + { event: 'cordis/request-run-resolved', mode: 'emit' }, + { event: 'cordis/dynamic-package', mode: 'emit' }, + { event: 'cordis/dynamic-retract', mode: 'emit' }, + { event: 'cordis/inspect-query', mode: 'emit' }, + { event: 'cordis/inspect-query-resolved', mode: 'emit' }, + { event: 'llm/adapters-updated', mode: 'emit' }, + { event: 'settings/document-updated', mode: 'emit' }, + { event: 'user-questions/request', mode: 'waterfall' }, +] as const satisfies readonly TypertForwardableEventEntry[] diff --git a/packages/api/remotes/src/types.ts b/packages/api/remotes/src/types.ts index 6e14546261..cbf7572d8a 100644 --- a/packages/api/remotes/src/types.ts +++ b/packages/api/remotes/src/types.ts @@ -12,7 +12,7 @@ import type { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' /** Type projection of the allowlist; the consumer and the Host read this one. */ -export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] +export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event'] declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertRemoteEventSelection extends Record {} diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts new file mode 100644 index 0000000000..eefe64e664 --- /dev/null +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -0,0 +1,216 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' +import type { + TypertRemoteEventInvocation, + TypertRemoteEventSource, +} from '@deepseek-ai/dsh-api-gateway' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import { describe, expect, it } from 'vitest' +import { apply, inject } from '../src/index.ts' + +interface GatewayProbe { + source: TypertRemoteEventSource | undefined + removals: number + registerRemoteEvents(source: TypertRemoteEventSource): () => Promise +} + +async function setup(): Promise<{ + readonly ctx: Context + readonly gateway: GatewayProbe + readonly fiber: Fiber +}> { + const ctx = new Context() + const gateway: GatewayProbe = { + source: undefined, + removals: 0, + registerRemoteEvents(source) { + gateway.source = source + return async () => { + if (gateway.source !== source) return + gateway.source = undefined + gateway.removals += 1 + } + }, + } + ctx.reflect.provide('typertGateway', gateway) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber + return { ctx, gateway, fiber } +} + +function sourceOf(gateway: GatewayProbe): TypertRemoteEventSource { + if (gateway.source === undefined) throw new Error('fixture Gateway has no Remote event source') + return gateway.source +} + +function emitRaw(ctx: Context, event: string, args: readonly unknown[]): void { + const emit = ctx.emit.bind(ctx) as unknown as (name: string, ...values: readonly unknown[]) => void + emit(event, ...args) +} + +function waterfallRaw( + ctx: Context, + target: object, + event: string, + args: readonly unknown[], + next: () => Promise, +): Promise { + const waterfall = ctx.waterfall.bind(ctx) as unknown as ( + receiver: object, + name: string, + ...values: readonly unknown[] + ) => Promise + return waterfall(target, event, ...args, next) +} + +function invocationOf(value: unknown): TypertRemoteEventInvocation { + if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'context')) { + throw new Error('fixture did not receive a scoped Remote Event invocation') + } + return value as TypertRemoteEventInvocation +} + +describe('Remote event Host source', () => { + it('gives each Client stream an independent allowlisted event queue', async () => { + const { ctx, gateway, fiber } = await setup() + const firstAbort = new AbortController() + const secondAbort = new AbortController() + const first = sourceOf(gateway)(firstAbort.signal)[Symbol.asyncIterator]() + const second = sourceOf(gateway)(secondAbort.signal)[Symbol.asyncIterator]() + + emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1]) + await expect(first.next()).resolves.toEqual({ + done: false, + value: { event: 'settings/document-updated', args: ['ui-theme', 1] }, + }) + await expect(second.next()).resolves.toEqual({ + done: false, + value: { event: 'settings/document-updated', args: ['ui-theme', 1] }, + }) + + const firstDone = first.next() + firstAbort.abort(new Error('first Client disconnected')) + emitRaw(ctx, 'commands/change', []) + await expect(firstDone).resolves.toEqual({ done: true, value: undefined }) + await expect(second.next()).resolves.toEqual({ + done: false, + value: { event: 'commands/change', args: [] }, + }) + + const secondDone = second.next() + secondAbort.abort(new Error('second Client disconnected')) + await expect(secondDone).resolves.toEqual({ done: true, value: undefined }) + + await fiber.dispose() + expect(gateway.source).toBeUndefined() + expect(gateway.removals).toBe(1) + await ctx.fiber.dispose() + }) + + it('rejects a non-JSON argument without poisoning the stream', async () => { + const { ctx, gateway } = await setup() + const abort = new AbortController() + const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const pending = iterator.next() + + expect(() => { + emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1n]) + }).toThrow('argument 1 is not lossless JSON data') + emitRaw(ctx, 'settings/document-updated', ['ui-theme', 2]) + await expect(pending).resolves.toEqual({ + done: false, + value: { event: 'settings/document-updated', args: ['ui-theme', 2] }, + }) + + const done = iterator.next() + abort.abort() + await expect(done).resolves.toEqual({ done: true, value: undefined }) + + const alreadyAborted = new AbortController() + alreadyAborted.abort() + await expect(sourceOf(gateway)(alreadyAborted.signal)[Symbol.asyncIterator]().next()) + .resolves.toEqual({ done: true, value: undefined }) + await ctx.fiber.dispose() + }) + + it('bridges scoped waterfall result, next delegation, and rejection', async () => { + const { ctx, gateway } = await setup() + const abort = new AbortController() + const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const agentCtx = ctx.extend() + const agent = { ctx: agentCtx } + const target = scopeTarget(ctx, agent) + const request = { questions: [], agent } + + const claimed = waterfallRaw( + ctx, + target, + 'user-questions/request', + [request], + () => Promise.resolve('host fallback'), + ) + const claimedDispatch = invocationOf((await iterator.next()).value) + expect(claimedDispatch).toMatchObject({ + event: 'user-questions/request', + request, + context: { value: agentCtx, subject: agent }, + }) + claimedDispatch.resolve({ kind: 'result', value: 'client answer' }) + await expect(claimed).resolves.toBe('client answer') + + const delegated = waterfallRaw( + ctx, + target, + 'user-questions/request', + [request], + () => Promise.resolve('host fallback'), + ) + const delegatedDispatch = invocationOf((await iterator.next()).value) + delegatedDispatch.resolve({ kind: 'next' }) + await expect(delegated).resolves.toBe('host fallback') + + const rejection = Object.assign(new Error('the user cancelled ask_user_question'), { + code: 'ASK_CANCELLED', + }) + const rejected = waterfallRaw( + ctx, + target, + 'user-questions/request', + [request], + () => Promise.resolve('host fallback'), + ) + const rejectedAssertion = expect(rejected).rejects.toBe(rejection) + const rejectedDispatch = invocationOf((await iterator.next()).value) + rejectedDispatch.reject(rejection) + await rejectedAssertion + + const done = iterator.next() + abort.abort() + await expect(done).resolves.toEqual({ done: true, value: undefined }) + await ctx.fiber.dispose() + }) + + it('rejects a queued scoped waterfall when its source is withdrawn', async () => { + const { ctx, gateway, fiber } = await setup() + const abort = new AbortController() + const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const delivery = iterator.next() + const agent = { ctx: ctx.extend() } + const reason = new Error('forwarded event source removed') + const pending = waterfallRaw( + ctx, + scopeTarget(ctx, agent), + 'user-questions/request', + [{ questions: [], agent }], + () => Promise.resolve('host fallback'), + ) + const rejected = expect(pending).rejects.toBe(reason) + + abort.abort(reason) + + await rejected + await expect(delivery).resolves.toEqual({ done: true, value: undefined }) + await fiber.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/client/connection/src/api-path.ts b/packages/client/connection/src/api-path.ts index f34aa231d4..ee54f61b03 100644 --- a/packages/client/connection/src/api-path.ts +++ b/packages/client/connection/src/api-path.ts @@ -1,14 +1,7 @@ /** * The /api URL prefix — single source for both halves of the web transport. - * The node half registers this prefix on the web server; both halves share the - * event paths below for the browser WebSocket downlinks. + * The node half registers this prefix on the web server. */ /** Route prefix owning every api request (`/api` and `/api/`). */ export const API_PATH = '/api' - -/** Browser mux-frame WebSocket pathname. */ -export const MUX_EVENTS_PATH = `${API_PATH}/events.mux` - -/** Browser host-frame WebSocket pathname. */ -export const HOST_EVENTS_PATH = `${API_PATH}/events.host` diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index ea8914ccc6..1065dfe876 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -13,15 +13,10 @@ * belongs to the webserver config, and this fence is not an auth layer. */ -import type { IncomingHttpHeaders } from 'node:http' import { isLoopbackHostname } from './loopback-hostname.ts' +import type { ConnectionTrustRequest } from './rpc.ts' -/** The request facts the fence reads from either HTTP representation. */ -interface ApiTrustRequest { - headers: IncomingHttpHeaders | Headers -} - -function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined { +function header(headers: ConnectionTrustRequest['headers'], name: string): string | undefined { if (headers instanceof Headers) return headers.get(name) ?? undefined const value = headers[name] return typeof value === 'string' ? value : undefined @@ -93,7 +88,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. */ -export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean { +export function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHosts: readonly string[]): boolean { // Host fence (DNS-rebinding defense), applied to every request: the browser // fills Host from the URL it believes it is talking to, so a rebound page // carries the attacker's domain here even though the socket lands on this diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 1b7627b293..e712ca5c3c 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -1,35 +1,32 @@ -// Central contract re-export point: every contract import inside -// web-runtime goes through this single file. +// Central contract re-export point: every legacy API contract import inside +// the Connection package goes through this browser-safe file. // Types and runtime protocol helpers/bounds come from the apiproxy api/ layer // (zero Node deps, browser-safe); AbstractApiClient is the client boundary. // NEVER import the package root: it drags bootHost/cordis into the browser bundle. // The ./api and ./client subpath exports are the browser-safe channels. export type { - ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApiProxy, HostApi, DirectoryEntry, DirectoryListing, - ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView, + ResponseValue, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, + ModelReasoningEffort, ModelSelection, GoalsApi, GoalRef, SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, - JobView, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, - ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, + ClientRequest, ServerResponse, RpcMessage, } from '@deepseek-ai/dsh-host-apiproxy/api' // transportError lives in the apiproxy api layer (beside RpcResult, its // subject); re-exported here so connection consumers keep one contract // entry point. export { RpcId, - SESSION_SEARCH_RESULT_LIMIT, transportError, } from '@deepseek-ai/dsh-host-apiproxy/api' export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 8b41053424..cad2de394d 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,4 +1,4 @@ -import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts' +import type { HostDescription, IApiClient } from './api.ts' /** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the * future `ctx.connection` plugin's Config). All fields optional; defaults below. */ @@ -9,18 +9,15 @@ export interface ConnectionConfig { backoffFactor?: number /** Upper bound for the backoff cap in ms. */ backoffMaxMs?: number - /** Cap on waiting for both streams' onOpen before onConnected, in ms. The strict handshake - * waits for mux+host stream establishment plus describe; a carrier that never - * fires onOpen (misbehaving proxy) must not wedge the connection forever — on timeout the - * generation proceeds as connected and the live-gap repair path covers stragglers. */ - streamOpenTimeoutMs?: number + /** Maximum wait for the registered generation source's ready signal. */ + generationReadyTimeoutMs?: number } const CONNECTION_DEFAULTS: Required = { backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000, - streamOpenTimeoutMs: 3_000, + generationReadyTimeoutMs: 3_000, } function sleep(ms: number, signal: AbortSignal): Promise { @@ -39,12 +36,9 @@ function sleep(ms: number, signal: AbortSignal): Promise { * 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */ export type ConnectionState = 'connected' | 'reconnecting' -/** Frame sink callbacks: the Controller owns the physical streams; business dispatch belongs to - * SessionManager. */ +/** Connection-generation callbacks owned by API Gateway. */ export interface ConnectionSinks { - onMuxEnvelope?: (envelope: RpcRequest) => void - onHostEnvelope?: (envelope: RpcRequest) => void - /** After each connection generation is established (both streams open + describe succeeded), first connect included. */ + /** After the generation source is ready and host.describe succeeds, first connect included. */ onConnected?: (description: HostDescription) => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect * span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */ @@ -52,11 +46,22 @@ export interface ConnectionSinks { } /** - * Opens both streams and keeps iterating (pull mode: nothing reads the socket and the tap - * never fires unless someone for-awaits), reconnecting with exponential backoff on loss. + * One long-lived source defining a Connection generation. The source must + * attach its incremental listeners before calling `ready`, then remain pending + * until the generation is lost or `signal` aborts. + * @param signal - cancellation for the current generation. + * @param ready - one-shot report that incremental delivery is attached. + * @returns a promise settling only when this generation ends or fails. + */ +export type ConnectionGenerationSource = ( + signal: AbortSignal, + ready: () => void, +) => Promise + +/** + * Opens the registered generation source, reconnecting with exponential backoff on loss. * State (generation/attempt) is instance-private, never in the store. - * The pump body feeds each frame to a sink (sink exceptions must - * not kill the pump — a broken business layer must not drag down the connection layer). + * Sink exceptions do not kill the generation loop. */ export class ConnectionController { private generation = 0 @@ -68,6 +73,7 @@ export class ConnectionController { constructor( private readonly api: IApiClient, + private readonly source: ConnectionGenerationSource, private readonly sinks: ConnectionSinks = {}, config: ConnectionConfig = {}, ) { @@ -81,7 +87,7 @@ export class ConnectionController { void this.loop() } - /** Stop the loop and abort the current generation's streams. */ + /** Stop the loop and abort the current generation source. */ stop(): void { this.running = false this.current?.abort() @@ -110,37 +116,58 @@ export class ConnectionController { const ac = new AbortController() this.current = ac - /* v8 ignore next -- initializer placeholder: the Promise executor - * below runs synchronously and replaces it before anyone can call it. */ - let muxOpened = (): void => {} - /* v8 ignore next -- same placeholder pattern as muxOpened. */ - let hostOpened = (): void => {} - const streamsOpen = Promise.all([ - new Promise((resolve) => { muxOpened = resolve }), - new Promise((resolve) => { hostOpened = resolve }), - ]) + let sourceReady = false + let resolveReady!: () => void + let rejectReady!: (error: Error) => void + let rejectSourceLost!: (error: Error) => void + const ready = new Promise((resolve, reject) => { + resolveReady = resolve + rejectReady = reject + }) + const sourceLost = new Promise((_resolve, reject) => { + rejectSourceLost = reject + }) + const reportReady = (): void => { + sourceReady = true + resolveReady() + } const failed = new Promise((resolve) => { const settle = (): void => { if (gen === this.generation && !ac.signal.aborted) ac.abort() resolve() } - void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle) - void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle) + void Promise.resolve() + .then(() => this.source(ac.signal, reportReady)) + .then( + () => { + const error = new Error('connection generation ended') + if (!sourceReady) rejectReady(error) + rejectSourceLost(error) + settle() + }, + (error: unknown) => { + const failure = error instanceof Error + ? error + : new Error('connection generation failed', { cause: error }) + if (!sourceReady) rejectReady(failure) + rejectSourceLost(failure) + settle() + }, + ) }) try { - // Strict readiness handshake: describe proves unary reachability, onOpen - // proves each physical stream is established before any frame — - // only then may onConnected fire, so the resync it triggers cannot outrun the - // subscribed baseline. The timeout guards against a carrier that never fires onOpen - // (see ConnectionConfig.streamOpenTimeoutMs). - const timeout = new AbortController() - const [description] = await Promise.all([ - this.api.host.describe({}), - Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]), + // The source reports ready only after its incremental listeners exist; + // describe may complete in parallel, but consumers see neither result + // until both sides of the baseline-plus-increment handshake are ready. + const [description] = await Promise.race([ + Promise.all([ + this.api.host.describe({}, ac.signal), + waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal), + ]), + sourceLost, ]) - timeout.abort() const descriptionResult = description.result if (!descriptionResult.ok) { throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`) @@ -162,7 +189,7 @@ export class ConnectionController { if (!this.isRunning()) return this.emitState('reconnecting') this.attempt += 1 - console.warn(`[web-runtime] connection lost, retry #${this.attempt}`) + console.warn(`[connection] connection lost, retry #${this.attempt}`) const idle = new AbortController() await sleep(this.backoffDelay(this.attempt), idle.signal) } @@ -175,28 +202,40 @@ export class ConnectionController { this.callSink(() => this.sinks.onStateChange?.(state)) } - private async pumpStream( - stream: AsyncIterable>, - sink: ((envelope: RpcRequest) => void) | undefined, - onEnd: () => void, - ): Promise { - try { - for await (const envelope of stream) { - if (envelope.payload.type === 'stream/error') break - if (sink !== undefined) this.callSink(() => { sink(envelope) }) - } - } catch { - // Stream loss: converge on onEnd, which triggers the shared reconnect. - } - onEnd() - } - /** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */ private callSink(fn: () => void): void { try { fn() } catch (error) { - console.error('[web-runtime] connection sink threw:', error) + console.error('[connection] connection sink threw:', error) } } } + +/** Await source readiness without letting a stalled carrier wedge startup forever. */ +function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + let settled = false + const timeout = setTimeout(() => { + finish(new Error(`connection generation was not ready within ${String(timeoutMs)}ms`)) + }, timeoutMs) + const aborted = (): void => { + finish(new Error('connection generation aborted', { cause: signal.reason })) + } + const finish = (error?: Error): void => { + if (settled) return + settled = true + clearTimeout(timeout) + signal.removeEventListener('abort', aborted) + if (error === undefined) resolve() + else reject(error) + } + signal.addEventListener('abort', aborted, { once: true }) + void ready.then( + () => { finish() }, + (error: unknown) => { + finish(error as Error) + }, + ) + }) +} diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 603cd2325c..729c518095 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1,5 +1,4 @@ -// Standalone browser fixture. It models server-owned frame rpcIds and echoes -// unary request rpcIds through the production carrier types. +// Standalone browser fixture for UI development without a server. import { createAssistantMessage, @@ -7,7 +6,7 @@ import { createUserMessage, isTokenDelta, } from '@deepseek-ai/dsh-llm/message' -import { CallId } from '@deepseek-ai/dsh-llm/brand' +import { CallId, type MessageId } from '@deepseek-ai/dsh-llm/brand' import type { AssistantMessage, ContentBlock, @@ -28,14 +27,261 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { - ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, - ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, - ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, + ApiProxy, ClientRequest, + ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse, + ToolCallView, ToolResultView, } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' -import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts' +import { AbstractApiClient, RpcId } from './api.ts' import { randomUuid } from './random-uuid.ts' -import type { ClientConnectionRpc } from '../rpc.ts' +import type { + ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, +} from '../rpc.ts' + +const FIXTURE_SESSION_SEARCH_RESULT_LIMIT = 20 + +interface FixtureSessionSummary { + readonly sessionId: SessionId + updatedAt: number + running: boolean + blank: boolean + readonly parentSessionId?: SessionId + readonly origin?: 'subagent' + readonly cwd?: string + readonly agentPreset?: string + readonly projections?: FixtureProjectionsBlock +} + +interface FixtureProjectionsBlock { + readonly asOfSeq: number + readonly values: Readonly> +} + +type FixtureToolView = + | { readonly for: 'call'; readonly view: ToolCallView } + | { readonly for: 'result'; readonly view: ToolResultView } + +interface FixtureHistoryEntry { + readonly event: SessionEvent + readonly view?: FixtureToolView +} + +type FixtureSessionAddress = + | { readonly kind: 'session'; readonly sessionId: SessionId } + | { + readonly kind: 'subagent' + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + readonly mode: 'one-shot' | 'continuable' + } + +interface FixtureFollowRequest { + readonly address: FixtureSessionAddress + readonly afterSeq?: number +} + +interface FixturePageRequest { + readonly address: FixtureSessionAddress + readonly beforeSeq?: number + readonly maxMessages?: number +} + +type FixtureFollowFrame = + | { readonly type: 'opened'; readonly cursor: number } + | ({ readonly type: 'event' } & FixtureHistoryEntry) + +type FixtureFollowEventFrame = Extract + +interface FixtureRemoteEventNotificationFrame { + readonly type: 'emit' + readonly event: string + readonly args: readonly unknown[] +} + +interface FixtureRemoteEventInvocationFrame { + readonly type: 'waterfall' + readonly event: string + readonly eventId: string + readonly agentId: SessionId + readonly request: Readonly> +} + +interface FixtureRemoteEventCancellationFrame { + readonly type: 'cancel' + readonly eventId: string +} + +type FixtureRemoteEventFrame = + | FixtureRemoteEventNotificationFrame + | FixtureRemoteEventInvocationFrame + | FixtureRemoteEventCancellationFrame + +interface FixtureRemoteEventResult { + readonly clientId: string + readonly eventId: string + readonly outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { + readonly kind: 'rejected' + readonly error: { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown + } + } +} + +interface FixtureRemoteEventReadyFrame { + readonly type: 'ready' + readonly clientId: string +} + +interface FixtureProjectionFrame { + readonly type: 'projection' + readonly sessionId: SessionId + readonly key: string + readonly value: unknown + readonly seq: number +} + +interface FixtureQuestionItem { + readonly id: string + readonly header?: string + readonly question: string + readonly detail?: string + readonly multiSelect?: boolean + readonly options?: readonly { readonly label: string; readonly description?: string }[] +} + +type FixtureControlFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly queues: Readonly> + readonly jobs: Readonly> + readonly approvals: readonly never[] + readonly questions: readonly never[] + readonly projections: Readonly> + } + } + | FixtureProjectionFrame + +type FixturePromptPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageAttachmentRef['mediaType'] + readonly data: string + readonly name?: string + } + +interface FixtureSessionApi { + list(request: { readonly cursor?: string }): Promise> + search( + request: { readonly query: string }, + signal: AbortSignal, + ): Promise> + create(request: { + readonly workspaceId?: WorkspaceId + readonly cwd?: string + readonly sessionId?: SessionId + readonly agentPreset?: string + }): Promise> + rename(request: { readonly sessionId: SessionId; readonly title: string }): Promise> + fork(request: { readonly sessionId: SessionId; readonly atSeq?: number }): Promise> + history(request: { + readonly sessionId: SessionId + readonly beforeSeq?: number + readonly maxMessages?: number + }): Promise> + models(request: { readonly sessionId: SessionId }): Promise> + selectModel(request: { + readonly sessionId: SessionId + readonly provider: string + readonly model: string + readonly reasoningEffort?: string + }): Promise> + prompt(request: { + readonly requestId: string + readonly sessionId: SessionId + readonly mode: 'queue' | 'steer' + readonly content: readonly FixturePromptPart[] + readonly clientTimeZone?: string + }): Promise> + attachment(request: { + readonly sessionId: SessionId + readonly attachmentId: AttachmentIdType + }): Promise> + updateQueue(request: { + readonly sessionId: SessionId + readonly itemId: MessageId + readonly action: unknown + }): Promise> + cancel(request: { readonly sessionId: SessionId }): Promise> +} + +type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } + +interface WorkspaceView { + readonly workspaceId: WorkspaceId + readonly path: string + readonly title: string + readonly sessionIds: readonly SessionId[] + readonly createdAt: string + readonly updatedAt: string +} + +interface WorkspaceCreateRequest { readonly path: string } +interface WorkspaceCreateValue { readonly workspace: WorkspaceView; readonly created: boolean } +interface WorkspaceRenameRequest { readonly workspaceId: WorkspaceId; readonly title: string } +interface WorkspaceValue { readonly workspace: WorkspaceView } +interface WorkspaceDeleteRequest { readonly workspaceId: WorkspaceId } +interface WorkspaceDeleteValue { readonly deleted: true } +interface WorkspaceInsertBeforeRequest { + readonly workspaceId: WorkspaceId + readonly beforeWorkspaceId?: WorkspaceId +} +interface WorkspaceOrderValue { readonly workspaceIds: readonly WorkspaceId[] } +interface WorkspaceInsertSessionBeforeRequest { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId +} +interface WorkspaceArchiveSessionRequest { readonly sessionId: SessionId } +interface WorkspaceArchiveValue { readonly archivedSessionIds: readonly SessionId[] } + +type WorkspaceFollowFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly items: readonly WorkspaceView[] + readonly archivedSessionIds: readonly SessionId[] + } + } + | { readonly type: 'upsert'; readonly workspace: WorkspaceView } + | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } + | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } + | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } + +interface FixtureWorkspaceApi { + create(request: WorkspaceCreateRequest): Promise> + rename(request: WorkspaceRenameRequest): Promise> + delete(request: WorkspaceDeleteRequest): Promise> + insertBefore(request: WorkspaceInsertBeforeRequest): Promise> + insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise> + archiveSession(request: WorkspaceArchiveSessionRequest): Promise> +} + +interface FixtureWorkspace { + workspaceId: WorkspaceId + path: string + title: string + sessionIds: SessionId[] + createdAt: string + updatedAt: string +} /** The fake carrier mints like a real one (business code never mints). */ function rpcRequest

(payload: P): RpcRequest

{ @@ -693,7 +939,7 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR } /** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */ -function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventView | undefined { +function viewFor(event: SessionEvent, log: readonly SessionEvent[]): FixtureToolView | undefined { if (event.type === 'tool/call') { const view = presentCall(event.data.name, event.data.arguments) return view === undefined ? undefined : { for: 'call', view } @@ -1065,20 +1311,24 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record[] { +/** Host parallel: emit one Session control projection frame per key advanced by the event. */ +function projectionFramesOf( + id: SessionId, + log: readonly SessionEvent[], + event: SessionEvent, +): FixtureProjectionFrame[] { const type = (event as { type: string }).type - const frames: Extract[] = [] + const frames: FixtureProjectionFrame[] = [] // One usage sample advances both token-meter units. if (usageSampleOf(event) !== undefined) { frames.push( - { type: 'session/projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq }, - { type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq }, + { type: 'projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq }, + { type: 'projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq }, ) } if (type === 'request/context') { frames.push({ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), @@ -1090,7 +1340,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: || type === 'assistant/message' || type === 'tool/result') { frames.push({ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'contextBreakdown', value: contextBreakdownOf(log), @@ -1101,7 +1351,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: // (wall times) and on step close (counts). if (type === 'assistant/message' || type === 'tool/result' || type === 'step/end') { frames.push({ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'sessionStats', value: sessionStatsOf(log), @@ -1113,16 +1363,16 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: const values = projectionValuesOf(log) /* v8 ignore next -- the advancing title event is in the log, so the key is present. */ if (!Object.hasOwn(values, 'title')) return [] - return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }] + return [{ type: 'projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }] } // The goal domain's own durable change advances its projection. if (type === 'goal/change') { - return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }] + return [{ type: 'projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }] } // Standing-plan fold: writes replace the list; turn/start clears it (null). if (type === 'todo/write' || type === 'turn/start') { return [{ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'todos', value: backscanTodos(log) ?? null, @@ -1132,7 +1382,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: // Knob fold: any of the three whole-value knob events advances the select. if (type === 'permission/preset' || type === 'sandbox/mode' || type === 'approval/policy') { return [{ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'permissions', value: permissionSelectOf(log), @@ -1145,7 +1395,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: if (type === 'plan/mode' || (type === 'command/run' && commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) { return [{ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'plan', value: planViewOf(log), @@ -1165,7 +1415,7 @@ function pageOf( log: readonly SessionEvent[], beforeSeq: number | undefined, maxMessages: number, -): { events: HistoryEntry[]; hasMore: boolean } { +): { events: FixtureHistoryEntry[]; hasMore: boolean } { const end = beforeSeq === undefined ? log.length : Math.max(0, Math.min(beforeSeq, log.length)) let start = 0 let messages = 0 @@ -1179,7 +1429,7 @@ function pageOf( break } } - const events = log.slice(start, end).map((event): HistoryEntry => { + const events = log.slice(start, end).map((event): FixtureHistoryEntry => { const view = viewFor(event, log) return view === undefined ? { event } : { event, view } }) @@ -1395,8 +1645,8 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null { return null } -interface StreamConn { - push(envelope: RpcRequest): void +interface StreamConn { + push(value: Value): void } interface ReasoningChunkStormState { @@ -1427,13 +1677,13 @@ export interface FixtureOptions { * outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and * piles up for the stream's lifetime). breakNow force-ends the stream without the * client's signal (timing hook: simulated connection loss). */ -class FxInbox implements StreamConn { - private readonly inbox: RpcRequest[] = [] +class FxInbox implements StreamConn { + private readonly inbox: Value[] = [] private wake: (() => void) | null = null private broken = false - push(envelope: RpcRequest): void { - this.inbox.push(envelope) + push(value: Value): void { + this.inbox.push(value) this.wake?.() } @@ -1447,12 +1697,12 @@ class FxInbox implements StreamConn { return !signal.aborted && !this.broken } - async *drain(signal: AbortSignal): AsyncGenerator> { + async *drain(signal: AbortSignal): AsyncGenerator { const onAbort = (): void => this.wake?.() signal.addEventListener('abort', onAbort) try { while (this.isLive(signal)) { - while (this.inbox.length > 0) yield this.inbox.shift() as RpcRequest + while (this.inbox.length > 0) yield this.inbox.shift() as Value if (!this.isLive(signal)) break await new Promise((resolve) => { this.wake = resolve @@ -1495,7 +1745,7 @@ export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld { /** Build the fixture's legacy API and Remote RPC faces over one state graph. */ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. - const sessions: SessionSummary[] = options.empty ? [] : [ + const sessions: FixtureSessionSummary[] = options.empty ? [] : [ { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' }, { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, @@ -1528,14 +1778,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { let fixtureDefaultPreset = 'standard' const nextTurn = new Map([[sid('fx-alpha'), 75]]) let nextSession = 1 - let nextRpc = 1 let attachedSessions = options.empty ? 0 : 1 // Workspace entities mirroring the host registry: the fixture sessions all // live under one workspace, whose account carries them in attach order. const wid = (raw: string): WorkspaceId => raw as WorkspaceId const fixtureEpoch = new Date(Date.now() - 300_000).toISOString() const FIXTURE_HOME = '/home/fixture' - const workspaces: WorkspaceView[] = options.empty ? [] : [{ + const workspaces: FixtureWorkspace[] = options.empty ? [] : [{ workspaceId: wid('fx-ws-fixture'), path: '/tmp/fixture', title: 'fixture', @@ -1554,6 +1803,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // Registry-global archive set mirroring the host: archived sessions keep // their workspace accounting slot and only grouping surfaces hide them. const archivedSessionIds: SessionId[] = [] + const workspaceSnapshot = (workspace: FixtureWorkspace): WorkspaceView => ({ + ...workspace, + sessionIds: [...workspace.sessionIds], + }) + const workspaceBaseline = (): Extract => ({ + type: 'baseline', + value: { + items: workspaces.map(workspaceSnapshot), + archivedSessionIds: [...archivedSessionIds], + }, + }) // In-memory browse tree behind the fixture's `browse` picker capability — // deterministic content mirroring the design mock so assembled Web tests @@ -1584,15 +1844,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } return crumbs } - const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) - /** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */ - const pendingApprovalRpcId = mint() - const pendingApprovalId = 'fx-approval-1' as Extract['approvalId'] - /** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */ - let approvalPending = true - const pendingQuestionRpcId = mint() - let questionPending = true - const fixtureQuestions: Extract['questions'] = [ + /** Resident waterfalls retain their event ids across Remote Event generations. */ + const pendingApprovalEventId = 'fx-interaction-approval' + let approvalPending = !options.empty + const pendingQuestionEventId = 'fx-interaction-question' + let questionPending = !options.empty + const fixtureQuestions: readonly FixtureQuestionItem[] = [ { id: 'harness-profile', header: '偏好', @@ -1626,13 +1883,24 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, ] - const muxConns = new Set>() - const hostConns = new Set>() - const emitMux = (frame: MuxFrame): void => { - for (const conn of muxConns) conn.push({ rpcId: mint(), payload: frame }) + const controlConns = new Set>() + const followConns = new Map>>() + const workspaceConns = new Set>() + const remoteEventConns = new Map>() + const emitControl = (frame: FixtureControlFrame): void => { + for (const conn of controlConns) conn.push(frame) } - const emitHost = (frame: HostFrame): void => { - for (const conn of hostConns) conn.push({ rpcId: mint(), payload: frame }) + const emitWorkspace = (frame: Exclude): void => { + for (const conn of workspaceConns) conn.push(frame) + } + const emitRemote = (event: string, args: readonly unknown[]): void => { + for (const conn of remoteEventConns.values()) conn.push({ type: 'emit', event, args }) + } + const emitRemoteFrame = (frame: FixtureRemoteEventFrame): void => { + for (const conn of remoteEventConns.values()) conn.push(frame) + } + const emitFollow = (sessionId: SessionId, entry: FixtureHistoryEntry): void => { + for (const conn of followConns.get(sessionId) ?? []) conn.push({ type: 'event', ...entry }) } /** OK response echoing the caller's rpcId (contract: responses always backfill, never mint). */ @@ -1643,7 +1911,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return Promise.resolve({ rpcId: request.rpcId, result: { ok: false, error } }) } - const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id) + function sessionOk(value: T): Promise> { + return Promise.resolve({ ok: true, value }) + } + + function sessionErr(error: ConnectionRpcFailure): Promise> { + return Promise.resolve({ ok: false, error }) + } + + const summaryOf = (id: SessionId): FixtureSessionSummary | undefined => sessions.find(s => s.sessionId === id) /** Shared session guard for sessionId-addressed catalog routes: the error * response when the session is unknown, undefined when it exists. */ const requireSession = (request: RpcRequest<{ sessionId: SessionId }>): Promise> | undefined => { @@ -1654,11 +1930,21 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { details: { sessionId: request.payload.sessionId }, }) } + const requireRemoteSession = ( + request: { readonly sessionId: SessionId }, + ): Promise> | undefined => { + if (summaryOf(request.sessionId) !== undefined) return undefined + return sessionErr({ + code: 'session-not-found', + message: `no session ${request.sessionId}`, + details: { sessionId: request.sessionId }, + }) + } const setRunning = (id: SessionId, running: boolean): void => { const summary = summaryOf(id) if (summary === undefined || summary.running === running) return summary.running = running - emitHost({ type: 'host/session-status', sessionId: id, running }) + emitRemote('api-session/status', [id, running]) } const logOf = (id: SessionId): SessionEvent[] => { let log = logs.get(id) @@ -1674,14 +1960,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { log.push(event) // Emission-time view derivation (mirrors the host's live path). const view = viewFor(event, log) - /* v8 ignore next 3 -- the view-present arm needs a live tool/call emission, + /* v8 ignore next 2 -- the view-present arm needs a live tool/call emission, but the fixture replay produces text-only turns; view vocabulary is exercised through the history samples (turns 60-62). */ - emitMux(view === undefined - ? { type: 'session/event', sessionId: id, event } - : { type: 'session/event', sessionId: id, event, view }) + emitFollow(id, view === undefined ? { event } : { event, view }) // Host eager-drive parallel: a unit-advancing event pushes its finished value. - for (const frame of projectionFramesOf(id, log, event)) emitMux(frame) + for (const frame of projectionFramesOf(id, log, event)) emitControl(frame) + if (event.type === 'user/message' && event.data.source.kind === 'user') { + const summary = summaryOf(id) + if (summary !== undefined) summary.updatedAt = event.time + emitRemote('api-session/activity', [id, event.time]) + } } /** Append one durable goal/change (host GoalService parallel). */ @@ -2012,7 +2301,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { failNextHistory(): void { failNextHistory = true }, - /** Log append + mux emit (the normal live path). */ + /** Log append plus follow-stream delivery (the normal live path). */ appendUser(id: string, msg: string): void { append(sid(id), { type: 'user/message', surfaceOp: 'append', data: userMessage(text(msg)) }) }, @@ -2180,7 +2469,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } }) setRunning(sessionId, false) }, - /** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */ + /** Log append without follow delivery: a frame lost in transit that page repair must recover. */ appendSilent(id: string, msg: string): void { const log = logOf(sid(id)) log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: userMessage(text(msg)) } as unknown as SessionEvent) @@ -2231,353 +2520,667 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { replays.set(id, { timer: setTimeout(tick, 80), finish }) } - const api: ApiProxy = { - sessions: { - list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), - search: (request, signal) => { - if (signal.aborted) { - return err(request, { - code: 'cancelled', - message: 'fixture session search was aborted', - details: {}, - }) - } - const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value) - const matches = sessions.flatMap((summary) => { - const log = logs.get(summary.sessionId) ?? [] - const current = new Set(foldSurface(log).nodes) - const best = log.flatMap((event): FixtureSearchCandidate[] => { - if (!current.has(event.seq)) return [] - const eventText = searchEventText(event) - const document = searchTokenSpans(eventText) - const match = phraseMatch(document.tokens, query) - if (match.count === 0) return [] - return [{ - sessionId: summary.sessionId, - seq: event.seq, - time: event.time, - text: document.text, - matchCount: match.count, - matchStart: match.start, - matchEnd: match.end, - documentLength: Array.from(eventText).length, - }] - }).sort(compareSearchCandidates)[0] - return best === undefined ? [] : [best] - }).sort(compareSearchCandidates) - return ok(request, { - items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({ - sessionId: match.sessionId, - snippet: searchSnippet(match.text, match.matchStart, match.matchEnd), - })), - hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT, + const sessionApi: FixtureSessionApi = { + list: _request => sessionOk({ items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), + search: (request, signal) => { + if (signal.aborted) { + return sessionErr({ + code: 'cancelled', + message: 'fixture session search was aborted', + details: {}, }) - }, - create: async (request) => { - const workspace = request.payload.workspaceId === undefined - ? undefined - : workspaces.find(w => w.workspaceId === request.payload.workspaceId) - if (request.payload.workspaceId !== undefined && workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${request.payload.workspaceId}`, - details: { workspaceId: request.payload.workspaceId }, - }) - } - const cwd = workspace?.path ?? request.payload.cwd ?? '/tmp/fixture' - const requestedId = request.payload.sessionId - const attachWorkspace = (sessionId: SessionId): void => { - /* v8 ignore next -- callers enter only when a target Workspace exists. */ - if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return - workspace.sessionIds = [sessionId, ...workspace.sessionIds] - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - const attachFailure = ( - sessionId: SessionId, - workspaceId: WorkspaceId, - ): Promise> => err(request, { - code: 'workspace-attach-failed' as const, - message: `fixture rejected Workspace attachment for ${sessionId}`, - details: { sessionId, workspaceId }, + } + const query = searchTokenSpans(request.query).tokens.map(token => token.value) + const matches = sessions.flatMap((summary) => { + const log = logs.get(summary.sessionId) ?? [] + const current = new Set(foldSurface(log).nodes) + const best = log.flatMap((event): FixtureSearchCandidate[] => { + if (!current.has(event.seq)) return [] + const eventText = searchEventText(event) + const document = searchTokenSpans(eventText) + const match = phraseMatch(document.tokens, query) + if (match.count === 0) return [] + return [{ + sessionId: summary.sessionId, + seq: event.seq, + time: event.time, + text: document.text, + matchCount: match.count, + matchStart: match.start, + matchEnd: match.end, + documentLength: Array.from(eventText).length, + }] + }).sort(compareSearchCandidates)[0] + return best === undefined ? [] : [best] + }).sort(compareSearchCandidates) + return sessionOk({ + items: matches.slice(0, FIXTURE_SESSION_SEARCH_RESULT_LIMIT).map(match => ({ + sessionId: match.sessionId, + snippet: searchSnippet(match.text, match.matchStart, match.matchEnd), + })), + hasMore: matches.length > FIXTURE_SESSION_SEARCH_RESULT_LIMIT, + }) + }, + create: async (request) => { + const workspace = request.workspaceId === undefined + ? undefined + : workspaces.find(w => w.workspaceId === request.workspaceId) + if (request.workspaceId !== undefined && workspace === undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, }) - if (requestedId !== undefined) { - const existing = summaryOf(requestedId) - if (existing !== undefined) { - if (existing.cwd !== cwd) { - return err(request, { - code: 'session-conflict', - message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`, - details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } }, - }) - } - if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) { - if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId) - attachWorkspace(requestedId) - } - return ok(request, { sessionId: requestedId }) + } + const cwd = workspace?.path ?? request.cwd ?? '/tmp/fixture' + const requestedId = request.sessionId + const attachWorkspace = (sessionId: SessionId): void => { + /* v8 ignore next -- callers enter only when a target Workspace exists. */ + if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return + workspace.sessionIds = [sessionId, ...workspace.sessionIds] + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + const attachFailure = ( + sessionId: SessionId, + workspaceId: WorkspaceId, + ): Promise> => sessionErr({ + code: 'workspace-attach-failed' as const, + message: `fixture rejected Workspace attachment for ${sessionId}`, + details: { sessionId, workspaceId }, + }) + if (requestedId !== undefined) { + const existing = summaryOf(requestedId) + if (existing !== undefined) { + if (existing.cwd !== cwd) { + return sessionErr({ + code: 'session-conflict', + message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`, + details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } }, + }) } + if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) { + if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId) + attachWorkspace(requestedId) + } + return sessionOk({ sessionId: requestedId }) } - const created: SessionSummary = { - sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, - } - sessions.push(created) - modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - attachedSessions += 1 - const emitSession = (): void => { - // The creation frame precedes later workspace-attachment work. - emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd }) - } - if (workspace !== undefined && options.failWorkspaceAttach) { - emitSession() - return attachFailure(created.sessionId, workspace.workspaceId) - } - if (workspace !== undefined && options.createFrameOrder === 'workspace-first') { - attachWorkspace(created.sessionId) - emitSession() - } else { - emitSession() - if (workspace !== undefined) attachWorkspace(created.sessionId) - } - if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication') - return ok(request, { sessionId: created.sessionId }) - }, - rename: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const { sessionId, title } = request.payload - const normalized = title.trim().replace(/\s+/g, ' ') - if (normalized.length === 0) { - return err(request, { - code: 'title-invalid', - message: 'session title must contain visible characters', - details: { sessionId }, - }) - } - // The append emits the session/event and its session/projection frame - // (host parallel); the unary response settles the caller first. - append(sessionId, { - type: 'session/title', - data: { title: normalized, messageSeqs: [], source: { kind: 'user' } }, + } + const created: FixtureSessionSummary = { + sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, + } + sessions.push(created) + modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + attachedSessions += 1 + const emitSession = (): void => { + emitRemote('api-session/added', [created]) + } + if (workspace !== undefined && options.failWorkspaceAttach) { + emitSession() + return attachFailure(created.sessionId, workspace.workspaceId) + } + if (workspace !== undefined && options.createFrameOrder === 'workspace-first') { + attachWorkspace(created.sessionId) + emitSession() + } else { + emitSession() + if (workspace !== undefined) attachWorkspace(created.sessionId) + } + if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication') + return sessionOk({ sessionId: created.sessionId }) + }, + rename: (request) => { + const missing = requireRemoteSession(request) + if (missing !== undefined) return missing + const { sessionId, title } = request + const normalized = title.trim().replace(/\s+/g, ' ') + if (normalized.length === 0) { + return sessionErr({ + code: 'title-invalid', + message: 'session title must contain visible characters', + details: { sessionId }, }) - const appended = logOf(sessionId).at(-1) as SessionEvent - return ok(request, { title: normalized, seq: appended.seq }) - }, - fork: (request) => { - const { sessionId, atSeq } = request.payload - const source = summaryOf(sessionId) - if (source === undefined) { - return err(request, { - code: 'session-not-found', - message: `no session ${sessionId}`, - details: { sessionId }, - }) - } - const log = logs.get(sessionId) ?? [] - const lastSeq = log.at(-1)?.seq ?? -1 - const anchoredBoundary = atSeq === undefined - ? undefined - : log.find(e => e.type === 'turn/end' && e.seq >= atSeq) - const boundary = anchoredBoundary + } + // The append emits the durable event and its control projection frame; + // the unary response settles the caller first. + append(sessionId, { + type: 'session/title', + data: { title: normalized, messageSeqs: [], source: { kind: 'user' } }, + }) + const appended = logOf(sessionId).at(-1) as SessionEvent + return sessionOk({ title: normalized, seq: appended.seq }) + }, + fork: (request) => { + const { sessionId, atSeq } = request + const source = summaryOf(sessionId) + if (source === undefined) { + return sessionErr({ + code: 'session-not-found', + message: `no session ${sessionId}`, + details: { sessionId }, + }) + } + const log = logs.get(sessionId) ?? [] + const lastSeq = log.at(-1)?.seq ?? -1 + const anchoredBoundary = atSeq === undefined + ? undefined + : log.find(e => e.type === 'turn/end' && e.seq >= atSeq) + const boundary = anchoredBoundary ?? (atSeq === undefined || atSeq > lastSeq ? log.findLast(e => e.type === 'turn/end') : undefined) - if (boundary === undefined) { - return err(request, { - code: 'fork-unavailable', - message: atSeq !== undefined && atSeq <= lastSeq - ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}` - : `session ${sessionId} has no completed turn`, - details: { sessionId }, - }) - } - let cut = boundary.seq + 1 - while (cut < log.length && log[cut]?.type !== 'turn/start') cut++ - const child: SessionSummary = { - sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false, - parentSessionId: sessionId, - ...source.cwd === undefined ? {} : { cwd: source.cwd }, - } - logs.set(child.sessionId, log.slice(0, cut)) - sessions.push(child) - emitHost({ - type: 'host/session-added', sessionId: child.sessionId, blank: false, - parentSessionId: sessionId, - ...source.cwd === undefined ? {} : { cwd: source.cwd }, + if (boundary === undefined) { + return sessionErr({ + code: 'fork-unavailable', + message: atSeq !== undefined && atSeq <= lastSeq + ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}` + : `session ${sessionId} has no completed turn`, + details: { sessionId }, }) - const workspace = workspaces.find(w => w.sessionIds.includes(sessionId)) - if (workspace !== undefined) { - workspace.sessionIds = [child.sessionId, ...workspace.sessionIds] - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - return ok(request, { sessionId: child.sessionId }) - }, - history: async (request) => { - const log = logs.get(request.payload.sessionId) ?? [] - // Snapshot at request time, then deliver after the transit delay. - const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50) - // Tail page carries the projections block (host parallel: one consistent - // cut over the registered units; asOfSeq = window tail seq, -1 on an - // empty log — the host's session.seq-1 convention). - const projections = request.payload.beforeSeq === undefined - ? { asOfSeq: log.length - 1, values: projectionValuesOf(log) } - : undefined - const doomed = failNextHistory - failNextHistory = false - const delay = historyDelayMs - if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) - if (doomed) throw new Error('fixture: simulated history transport failure') - return ok(request, { ...page, ...projections === undefined ? {} : { projections } }) - }, - models: request => ok(request, { - current: modelSelections.get(request.payload.sessionId) - ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - // The fixture's routes all serve; a surface exercising the blocked - // posture drives it through its own stub. - routable: true, - groups: fixtureModelGroups(), - failures: [], - }), - selectModel: (request) => { - const selected: ModelSelection = { - provider: request.payload.provider, - model: request.payload.model, - ...request.payload.reasoningEffort === undefined - ? {} - : { reasoningEffort: request.payload.reasoningEffort }, - } - modelSelections.set(request.payload.sessionId, selected) - return ok(request, { selected }) - }, - prompt: (request) => { - const { sessionId: id, mode, content } = request.payload - const summary = summaryOf(id) - if (summary === undefined) { - return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) - } - if (options.rejectPrompt) { - if (content.some(block => block.type === 'image')) { - return err(request, { - code: 'attachment-error', - message: 'fixture: image side exceeds the deployment limit', - details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' }, - }) - } - return err(request, { - code: 'agent-busy', - message: 'fixture: prompt rejected before acceptance', - details: { reason: 'fixture-prompt-rejection' }, - }) - } - summary.updatedAt = Date.now() - // First accepted prompt appends events: the summary stops being blank. - summary.blank = false - const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') - const durable: ContentBlock[] = content.map((block) => { - if (block.type === 'text') return block - const attachment: ImageAttachmentRef = { - attachmentId: `fixture:${randomUuid()}` as AttachmentIdType, - mediaType: block.mediaType, - bytes: Math.max( - 1, - Math.floor(block.data.length * 3 / 4) - - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0), - ), - width: 160, - height: 90, - ...block.name === undefined ? {} : { name: block.name }, - } - attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) - return { type: 'image', attachment } - }) - if (mode === 'steer' && replays.has(id)) { - // Steering: the durable user/message lands inside the current turn; the replay continues. - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) - return ok(request, { accepted: true as const }) - } - const turn = nextTurn.get(id) ?? 0 - nextTurn.set(id, turn + 1) - setRunning(id, true) - append(id, { type: 'turn/start', data: { turn } }) - // Boundary flush parallel (the host's step/start observer): an outstanding - // /plan selection commits as plan/mode inside the opened turn. - const plan = foldPlan(logOf(id)) - if (plan.wanted !== null && plan.wanted !== plan.active) { - append(id, { type: 'plan/mode', data: { active: plan.wanted } }) - } - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) - // Capacity parallel of the host token-meter's request/context record: - // log-only, appended inside the open turn, and deduplicated against the - // route already recorded (the fixture never varies contextWindow). - const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' } - if (lastRequestContext(logOf(id))?.model !== selection.model) { - append(id, { - type: 'request/context', - data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 }, - }) - } - startReply( - id, - turn, - userText === 'render markdown' - ? MARKDOWN_FIXTURE - : userText === 'report model' - ? (() => { - const selection = modelSelections.get(id) - return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}` - + (selection?.reasoningEffort === undefined ? '' : ` · 推理等级:${selection.reasoningEffort}`) - })() - : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, - ) - return ok(request, { accepted: true as const }) - }, - attachment: (request) => { - const stored = attachments.get(String(request.payload.attachmentId)) - if (stored === undefined) { - return err(request, { - code: 'attachment-error', - message: 'fixture attachment missing', - details: { reason: 'ATTACHMENT_NOT_FOUND' }, - }) - } - if (!logReferencesAttachment( - logs.get(request.payload.sessionId) ?? [], - String(request.payload.attachmentId), - )) { - return err(request, { - code: 'attachment-error', - message: 'fixture attachment is not referenced by this session', - details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, - }) - } - return ok(request, stored) - }, - updateQueue: request => err(request, { - code: 'queue-item-not-found', - message: 'fixture has no pending queue item', - details: { itemId: request.payload.itemId }, - }), - cancel: (request) => { - const replay = replays.get(request.payload.sessionId) - if (replay !== undefined) { - clearTimeout(replay.timer) - replay.finish(true) - } else { - setRunning(request.payload.sessionId, false) - } - return ok(request, { accepted: true as const }) - }, + } + let cut = boundary.seq + 1 + while (cut < log.length && log[cut]?.type !== 'turn/start') cut++ + const child: FixtureSessionSummary = { + sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false, + parentSessionId: sessionId, + ...source.cwd === undefined ? {} : { cwd: source.cwd }, + } + logs.set(child.sessionId, log.slice(0, cut)) + sessions.push(child) + emitRemote('api-session/added', [child]) + const workspace = workspaces.find(w => w.sessionIds.includes(sessionId)) + if (workspace !== undefined) { + workspace.sessionIds = [child.sessionId, ...workspace.sessionIds] + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + return sessionOk({ sessionId: child.sessionId }) }, + history: async (request) => { + const log = logs.get(request.sessionId) ?? [] + // Snapshot at request time, then deliver after the transit delay. + const page = pageOf(log, request.beforeSeq, request.maxMessages ?? 50) + // Tail page carries the projections block (host parallel: one consistent + // cut over the registered units; asOfSeq = window tail seq, -1 on an + // empty log — the host's session.seq-1 convention). + const projections = request.beforeSeq === undefined + ? { asOfSeq: log.length - 1, values: projectionValuesOf(log) } + : undefined + const doomed = failNextHistory + failNextHistory = false + const delay = historyDelayMs + if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) + if (doomed) throw new Error('fixture: simulated history transport failure') + return sessionOk({ ...page, ...projections === undefined ? {} : { projections } }) + }, + models: request => sessionOk({ + current: modelSelections.get(request.sessionId) + ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + // The fixture's routes all serve; a surface exercising the blocked + // posture drives it through its own stub. + routable: true, + groups: fixtureModelGroups(), + failures: [], + }), + selectModel: (request) => { + const selected: ModelSelection = { + provider: request.provider, + model: request.model, + ...request.reasoningEffort === undefined + ? {} + : { reasoningEffort: request.reasoningEffort }, + } + modelSelections.set(request.sessionId, selected) + return sessionOk({ selected }) + }, + prompt: (request) => { + const { sessionId: id, mode, content } = request + const summary = summaryOf(id) + if (summary === undefined) { + return sessionErr({ code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) + } + if (options.rejectPrompt) { + if (content.some(block => block.type === 'image')) { + return sessionErr({ + code: 'attachment-error', + message: 'fixture: image side exceeds the deployment limit', + details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' }, + }) + } + return sessionErr({ + code: 'agent-busy', + message: 'fixture: prompt rejected before acceptance', + details: { reason: 'fixture-prompt-rejection' }, + }) + } + summary.updatedAt = Date.now() + // First accepted prompt appends events: the summary stops being blank. + summary.blank = false + const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') + const durable: ContentBlock[] = content.map((block) => { + if (block.type === 'text') return block + const attachment: ImageAttachmentRef = { + attachmentId: `fixture:${randomUuid()}` as AttachmentIdType, + mediaType: block.mediaType, + bytes: Math.max( + 1, + Math.floor(block.data.length * 3 / 4) + - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0), + ), + width: 160, + height: 90, + ...block.name === undefined ? {} : { name: block.name }, + } + attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) + return { type: 'image', attachment } + }) + if (mode === 'steer' && replays.has(id)) { + // Steering: the durable user/message lands inside the current turn; the replay continues. + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + return sessionOk({ accepted: true as const }) + } + const turn = nextTurn.get(id) ?? 0 + nextTurn.set(id, turn + 1) + setRunning(id, true) + append(id, { type: 'turn/start', data: { turn } }) + // Boundary flush parallel (the host's step/start observer): an outstanding + // /plan selection commits as plan/mode inside the opened turn. + const plan = foldPlan(logOf(id)) + if (plan.wanted !== null && plan.wanted !== plan.active) { + append(id, { type: 'plan/mode', data: { active: plan.wanted } }) + } + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + // Capacity parallel of the host token-meter's request/context record: + // log-only, appended inside the open turn, and deduplicated against the + // route already recorded (the fixture never varies contextWindow). + const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' } + if (lastRequestContext(logOf(id))?.model !== selection.model) { + append(id, { + type: 'request/context', + data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 }, + }) + } + startReply( + id, + turn, + userText === 'render markdown' + ? MARKDOWN_FIXTURE + : userText === 'report model' + ? (() => { + const selection = modelSelections.get(id) + return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}` + + (selection?.reasoningEffort === undefined ? '' : ` · 推理等级:${selection.reasoningEffort}`) + })() + : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, + ) + return sessionOk({ accepted: true as const }) + }, + attachment: (request) => { + const stored = attachments.get(String(request.attachmentId)) + if (stored === undefined) { + return sessionErr({ + code: 'attachment-error', + message: 'fixture attachment missing', + details: { reason: 'ATTACHMENT_NOT_FOUND' }, + }) + } + if (!logReferencesAttachment( + logs.get(request.sessionId) ?? [], + String(request.attachmentId), + )) { + return sessionErr({ + code: 'attachment-error', + message: 'fixture attachment is not referenced by this session', + details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, + }) + } + return sessionOk(stored) + }, + updateQueue: request => sessionErr({ + code: 'queue-item-not-found', + message: 'fixture has no pending queue item', + details: { itemId: request.itemId }, + }), + cancel: (request) => { + const replay = replays.get(request.sessionId) + if (replay !== undefined) { + clearTimeout(replay.timer) + replay.finish(true) + } else { + setRunning(request.sessionId, false) + } + return sessionOk({ accepted: true as const }) + }, + } + + const controlBaseline = (): Extract => { + const queues: Record = {} + const jobs: Record = {} + const projections: Record = {} + for (const summary of sessions) { + queues[summary.sessionId] = [] + jobs[summary.sessionId] = [] + const log = logs.get(summary.sessionId) ?? [] + projections[summary.sessionId] = { + asOfSeq: log.length - 1, + values: projectionValuesOf(log), + } + } + return { + type: 'baseline', + value: { + queues, + jobs, + approvals: [], + questions: [], + projections, + }, + } + } + + const approvalInvocation = (): FixtureRemoteEventInvocationFrame => ({ + type: 'waterfall', + event: 'approval/request', + eventId: pendingApprovalEventId, + agentId: sid('fx-alpha'), + request: { + toolName: 'dangerous_tool', + reason: 'fixture 常驻审批(可答:批准/拒绝后消失)', + }, + }) + + const questionInvocation = (): FixtureRemoteEventInvocationFrame => ({ + type: 'waterfall', + event: 'user-questions/request', + eventId: pendingQuestionEventId, + agentId: sid('fx-alpha'), + request: { + questions: fixtureQuestions, + }, + }) + + async function* openControl(signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const conn = new FxInbox() + controlConns.add(conn) + const breakNow = (): void => { conn.breakNow() } + streamBreakers.add(breakNow) + try { + yield controlBaseline() + yield* conn.drain(signal) + } finally { + streamBreakers.delete(breakNow) + controlConns.delete(conn) + } + } + + async function* openWorkspace(signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const conn = new FxInbox() + workspaceConns.add(conn) + const breakNow = (): void => { conn.breakNow() } + streamBreakers.add(breakNow) + try { + yield workspaceBaseline() + yield* conn.drain(signal) + } finally { + streamBreakers.delete(breakNow) + workspaceConns.delete(conn) + } + } + + async function* openRemoteEvents( + signal: AbortSignal, + ): AsyncGenerator { + signal.throwIfAborted() + const clientId = randomUuid() + const conn = new FxInbox() + remoteEventConns.set(clientId, conn) + // Periodic material for the RPC-panel acceptance: flip fx-gamma every 5s. + // fx-gamma only; the conversation replay owns fx-alpha's running state. + const timer = setInterval(() => { + const gamma = summaryOf(sid('fx-gamma')) + /* v8 ignore next -- the fixture never removes fx-gamma. */ + if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running) + }, 5000) + try { + yield { type: 'ready', clientId } + if (approvalPending) yield approvalInvocation() + if (questionPending) yield questionInvocation() + yield* conn.drain(signal) + } finally { + clearInterval(timer) + remoteEventConns.delete(clientId) + } + } + + async function* openFollow( + request: FixtureFollowRequest, + signal: AbortSignal, + ): AsyncGenerator { + signal.throwIfAborted() + const sessionId = request.address.kind === 'session' + ? request.address.sessionId + : request.address.childSessionId + if (summaryOf(sessionId) === undefined) throw new Error(`fixture: no session ${sessionId}`) + const conn = new FxInbox() + let conns = followConns.get(sessionId) + if (conns === undefined) { + conns = new Set() + followConns.set(sessionId, conns) + } + conns.add(conn) + const breakNow = (): void => { conn.breakNow() } + streamBreakers.add(breakNow) + const snapshot = [...logOf(sessionId)] + const cursor = snapshot.at(-1)?.seq ?? -1 + if (request.afterSeq !== undefined && request.afterSeq > cursor) { + throw new Error( + `fixture: session event resume seq ${String(request.afterSeq)} is past cursor ${String(cursor)}`, + ) + } + let nextSeq = (request.afterSeq ?? cursor) + 1 + try { + yield { type: 'opened', cursor } + if (request.afterSeq !== undefined) { + for (const event of snapshot) { + if (event.seq < nextSeq) continue + if (event.seq !== nextSeq) { + throw new Error(`fixture: session event replay skipped seq ${String(nextSeq)}`) + } + nextSeq++ + const view = viewFor(event, snapshot) + yield view === undefined ? { type: 'event', event } : { type: 'event', event, view } + } + } + for await (const frame of conn.drain(signal)) { + if (frame.event.seq < nextSeq) continue + if (frame.event.seq !== nextSeq) { + throw new Error(`fixture: session event stream skipped seq ${String(nextSeq)}`) + } + nextSeq++ + yield frame + } + } finally { + streamBreakers.delete(breakNow) + conns.delete(conn) + if (conns.size === 0) followConns.delete(sessionId) + } + } + + const answerRemoteEvent = (result: FixtureRemoteEventResult): ConnectionRpcResult => { + if (!remoteEventConns.has(result.clientId)) { + return { + ok: false, + error: { + code: 'invocation-unavailable', + message: 'fixture Remote event result identifies no active event stream', + details: {}, + }, + } + } + if (result.eventId === pendingApprovalEventId) { + if (!approvalPending) return { ok: true, value: undefined } + approvalPending = false + } else if (result.eventId === pendingQuestionEventId) { + if (!questionPending) return { ok: true, value: undefined } + questionPending = false + } else { + return { ok: true, value: undefined } + } + emitRemoteFrame({ type: 'cancel', eventId: result.eventId }) + return { ok: true, value: undefined } + } + + const workspaceApi: FixtureWorkspaceApi = { + create: (request) => { + const existing = workspaces.find(workspace => workspace.path === request.path) + if (existing !== undefined) { + return sessionOk({ workspace: workspaceSnapshot(existing), created: false }) + } + const now = new Date().toISOString() + const created: FixtureWorkspace = { + workspaceId: wid(`fx-ws-${nextWorkspace++}`), + path: request.path, + title: request.path.split('/').filter(Boolean).at(-1) ?? request.path, + sessionIds: [], + createdAt: now, + updatedAt: now, + } + workspaces.unshift(created) + const workspace = workspaceSnapshot(created) + emitWorkspace({ type: 'upsert', workspace }) + return sessionOk({ workspace, created: true }) + }, + rename: (request) => { + const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) + if (workspace === undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, + }) + } + const title = request.title.trim() + if (title === '') { + return sessionErr({ + code: 'bad-request', + message: 'Workspace rename requires a non-blank title', + details: {}, + }) + } + if (title !== workspace.title) { + if (workspaces.some(candidate => candidate.workspaceId !== request.workspaceId && candidate.title === title)) { + return sessionErr({ + code: 'workspace-name-conflict', + message: `workspace name '${title}' is already in use`, + details: { name: title }, + }) + } + workspace.title = title + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + return sessionOk({ workspace: workspaceSnapshot(workspace) }) + }, + delete: (request) => { + const index = workspaces.findIndex(workspace => workspace.workspaceId === request.workspaceId) + if (index === -1) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, + }) + } + workspaces.splice(index, 1) + emitWorkspace({ type: 'remove', workspaceId: request.workspaceId }) + return sessionOk({ deleted: true }) + }, + insertBefore: (request) => { + const source = workspaces.findIndex(workspace => workspace.workspaceId === request.workspaceId) + const anchor = request.beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(workspace => workspace.workspaceId === request.beforeWorkspaceId) + const missing = source === -1 + ? request.workspaceId + : anchor === -1 + ? request.beforeWorkspaceId + : undefined + if (missing !== undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${missing}`, + details: { workspaceId: missing }, + }) + } + if (request.beforeWorkspaceId !== request.workspaceId) { + const previousOrder = workspaces.map(workspace => workspace.workspaceId) + const [workspace] = workspaces.splice(source, 1) + /* v8 ignore next -- source was resolved from the same array immediately above. */ + if (workspace === undefined) throw new Error(`fixture lost workspace ${request.workspaceId}`) + const at = request.beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(candidate => candidate.workspaceId === request.beforeWorkspaceId) + workspaces.splice(at, 0, workspace) + if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) { + emitWorkspace({ + type: 'order', + workspaceIds: workspaces.map(candidate => candidate.workspaceId), + }) + } + } + return sessionOk({ workspaceIds: workspaces.map(candidate => candidate.workspaceId) }) + }, + insertSessionBefore: (request) => { + const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) + if (workspace === undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, + }) + } + if (!workspace.sessionIds.includes(request.sessionId) + || (request.beforeSessionId !== undefined && !workspace.sessionIds.includes(request.beforeSessionId))) { + return sessionErr({ + code: 'workspace-move-invalid', + message: `session or anchor is not accounted by workspace ${request.workspaceId}`, + details: { + workspaceId: request.workspaceId, + sessionId: request.sessionId, + ...request.beforeSessionId === undefined ? {} : { beforeSessionId: request.beforeSessionId }, + }, + }) + } + const without = workspace.sessionIds.filter(id => id !== request.sessionId) + const at = request.beforeSessionId === undefined ? without.length : without.indexOf(request.beforeSessionId) + const sessionIds = [...without.slice(0, at), request.sessionId, ...without.slice(at)] + if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) { + workspace.sessionIds = sessionIds + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + return sessionOk({ workspace: workspaceSnapshot(workspace) }) + }, + archiveSession: (request) => { + if (summaryOf(request.sessionId) === undefined) { + return sessionErr({ + code: 'session-not-found', + message: `no session ${request.sessionId}`, + details: { sessionId: request.sessionId }, + }) + } + if (!archivedSessionIds.includes(request.sessionId)) { + archivedSessionIds.push(request.sessionId) + emitWorkspace({ type: 'archived', archivedSessionIds: [...archivedSessionIds] }) + } + return sessionOk({ archivedSessionIds: [...archivedSessionIds] }) + }, + } + + const api: ApiProxy = { subagents: { list: request => ok(request, { entries: [], parentAvailable: true }), - history: (request) => { - const log = logs.get(request.payload.childSessionId) ?? [] - return Promise.resolve(ok( - request, - pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50), - )) - }, prompt: request => Promise.resolve(ok(request, { messageId: `fixture-message-${request.payload.childSessionId}` as never, })), @@ -2625,138 +3228,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, openPath: request => ok(request, { opened: true as const }), }, - workspace: { - list: request => ok(request, { - items: workspaces.map(w => ({ ...w })), - archivedSessionIds: [...archivedSessionIds], - }), - create: (request) => { - const { path } = request.payload - const existing = workspaces.find(w => w.path === path) - if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false }) - const now = new Date().toISOString() - const created: WorkspaceView = { - workspaceId: wid(`fx-ws-${nextWorkspace++}`), - path, - title: path.split('/').filter(Boolean).at(-1) ?? path, - sessionIds: [], - createdAt: now, - updatedAt: now, - } - workspaces.unshift(created) - emitHost({ type: 'host/workspace-changed', workspace: { ...created } }) - return ok(request, { workspace: { ...created }, created: true }) - }, - rename: (request) => { - const { workspaceId, title } = request.payload - const workspace = workspaces.find(w => w.workspaceId === workspaceId) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${workspaceId}`, - details: { workspaceId }, - }) - } - const trimmed = title.trim() - if (trimmed !== workspace.title) { - if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) { - return err(request, { - code: 'workspace-name-conflict', - message: `workspace name '${trimmed}' is already in use`, - details: { name: trimmed }, - }) - } - workspace.title = trimmed - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - return ok(request, { workspace: { ...workspace } }) - }, - delete: (request) => { - const { workspaceId } = request.payload - const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) - if (index === -1) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${workspaceId}`, - details: { workspaceId }, - }) - } - workspaces.splice(index, 1) - emitHost({ type: 'host/workspace-removed', workspaceId }) - return ok(request, { deleted: true as const }) - }, - insertBefore: (request) => { - const { workspaceId, beforeWorkspaceId } = request.payload - const source = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) - const anchor = beforeWorkspaceId === undefined - ? workspaces.length - : workspaces.findIndex(workspace => workspace.workspaceId === beforeWorkspaceId) - const missing = source === -1 ? workspaceId : anchor === -1 ? beforeWorkspaceId : undefined - if (missing !== undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${missing}`, - details: { workspaceId: missing }, - }) - } - if (beforeWorkspaceId !== workspaceId) { - const previousOrder = workspaces.map(candidate => candidate.workspaceId) - const [workspace] = workspaces.splice(source, 1) - /* v8 ignore next -- source was resolved from the same array immediately above. */ - if (workspace === undefined) throw new Error(`fixture lost workspace ${workspaceId}`) - const at = beforeWorkspaceId === undefined - ? workspaces.length - : workspaces.findIndex(candidate => candidate.workspaceId === beforeWorkspaceId) - workspaces.splice(at, 0, workspace) - if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) { - emitHost({ - type: 'host/workspace-order-changed', - workspaceIds: workspaces.map(candidate => candidate.workspaceId), - }) - } - } - return ok(request, { workspaceIds: workspaces.map(candidate => candidate.workspaceId) }) - }, - insertSessionBefore: (request) => { - const { workspaceId, sessionId, beforeSessionId } = request.payload - const workspace = workspaces.find(w => w.workspaceId === workspaceId) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${workspaceId}`, - details: { workspaceId }, - }) - } - if (!workspace.sessionIds.includes(sessionId) - || (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) { - return err(request, { - code: 'workspace-move-invalid', - message: `session or anchor is not accounted by workspace ${workspaceId}`, - details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } }, - }) - } - const without = workspace.sessionIds.filter(id => id !== sessionId) - const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId) - const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)] - if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) { - workspace.sessionIds = sessionIds - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - return ok(request, { workspace: { ...workspace } }) - }, - archiveSession: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const { sessionId } = request.payload - if (!archivedSessionIds.includes(sessionId)) { - archivedSessionIds.push(sessionId) - emitHost({ type: 'host/archived-sessions-changed', archivedSessionIds: [...archivedSessionIds] }) - } - return ok(request, { archivedSessionIds: [...archivedSessionIds] }) - }, - }, agentPresets: { // Both trusts appear, because a surface must present a locally authored // preset differently from one the deployment vetted. @@ -2891,69 +3362,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { ), ), }, - events: { - async *mux(_request, signal) { - const conn = new FxInbox() - muxConns.add(conn) - const breakNow = (): void => { conn.breakNow() } - streamBreakers.add(breakNow) - // Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds. - for (const s of sessions) { - if (!s.running) continue - const log = logs.get(s.sessionId) ?? [] - conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: log.length - 1 } }) - // Post-subscribe projection baseline (host parallel: recomputed unit values ride push frames). - const values = projectionValuesOf(log) - for (const key of Object.keys(values)) { - conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } }) - } - } - if (approvalPending) { - conn.push({ - rpcId: pendingApprovalRpcId, - payload: { - type: 'approval/requested', sessionId: sid('fx-alpha'), - approvalId: pendingApprovalId, - toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)', - }, - }) - } - if (questionPending) { - conn.push({ - rpcId: pendingQuestionRpcId, - payload: { - type: 'question/requested', sessionId: sid('fx-alpha'), questions: fixtureQuestions, - }, - }) - } - try { - yield* conn.drain(signal) - } finally { - streamBreakers.delete(breakNow) - muxConns.delete(conn) - } - }, - async *host(_request, signal) { - const conn = new FxInbox() - hostConns.add(conn) - const breakNow = (): void => { conn.breakNow() } - streamBreakers.add(breakNow) - // Periodic material (the RPC-panel acceptance's clear-then-new-frames step depends on it): flip fx-gamma every 5s. - // fx-gamma only: never touch fx-alpha's running semantics (the conversation replay drives that). - const timer = setInterval(() => { - const gamma = summaryOf(sid('fx-gamma')) - /* v8 ignore next -- the undefined arm needs fx-gamma deleted, but the fixture never removes sessions. */ - if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running) - }, 5000) - try { - yield* conn.drain(signal) - } finally { - clearInterval(timer) - streamBreakers.delete(breakNow) - hostConns.delete(conn) - } - }, - }, settings: { // Only the resolved DeepSeek address needed by first-run readiness is // represented here. Fixture-backed journeys do not open its Models @@ -3024,31 +3432,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))), }), }, - respond(message: ClientResponse): Promise { - // Same routing discipline as the host: rpcId first, then the payload's - // audit correlation; a settled or unknown id is not-pending. - if (message.rpcId === pendingApprovalRpcId) { - if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' }) - if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) - const value = message.result.value as { approvalId?: unknown; outcome?: unknown } - if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) { - return Promise.resolve({ accepted: false, reason: 'bad-response' }) - } - approvalPending = false - emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome }) - return Promise.resolve({ accepted: true }) - } - if (!questionPending || message.rpcId !== pendingQuestionRpcId) { - return Promise.resolve({ accepted: false, reason: 'not-pending' }) - } - questionPending = false - emitMux({ - type: 'question/resolved', sessionId: sid('fx-alpha'), - questionRpcId: pendingQuestionRpcId, - outcome: message.result.ok ? 'answered' : 'cancelled', - }) - return Promise.resolve({ accepted: true }) - }, // Satisfies the ApiProxy contract type only: the browser export button // hands GET /api/session.export to the native download manager, so this // stub is never reached through the fixture's dispatch. @@ -3058,48 +3441,125 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } const rpc: ClientConnectionRpc = { - call(channel, endpoint, payload) { + call(channel, endpoint, payload, signal) { if (channel !== '/api') { return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`)) } const args = (payload as { - args: { + args: Readonly<{ agentId: SessionId line?: string query?: string images?: readonly unknown[] ref?: { id: string; revision: number } - request?: { objective?: string; maxGoalRounds?: number } - } + request?: unknown + _request?: unknown + }> }).args const sessionId = args.agentId + const callSignal = signal ?? new AbortController().signal + const request = args.request switch (endpoint) { case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { - objective: args.request?.objective as string, - ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, + objective: (request as { objective?: string } | undefined)?.objective as string, + ...(request as { maxGoalRounds?: number } | undefined)?.maxGoalRounds === undefined + ? {} + : { maxGoalRounds: (request as { maxGoalRounds: number }).maxGoalRounds }, })) - case 'goals/edit': return Promise.resolve(goalRemotes.edit(sessionId, args.ref as FxGoalRef, args.request ?? {})) + case 'goals/edit': return Promise.resolve(goalRemotes.edit( + sessionId, + args.ref as FxGoalRef, + request as { objective?: string; maxGoalRounds?: number }, + )) case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef)) case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef)) case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef)) case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef)) + case 'session/list': return sessionApi.list( + args._request as Parameters[0], + ) + case 'session/search': return sessionApi.search( + request as Parameters[0], + callSignal, + ) + case 'session/create': return sessionApi.create( + request as Parameters[0], + ) + case 'session/models': return sessionApi.models( + request as Parameters[0], + ) + case 'session/selectModel': return sessionApi.selectModel( + request as Parameters[0], + ) + case 'session/rename': return sessionApi.rename( + request as Parameters[0], + ) + case 'session/fork': return sessionApi.fork( + request as Parameters[0], + ) + case 'session/prompt': return sessionApi.prompt( + request as Parameters[0], + ) + case 'session/attachment': return sessionApi.attachment( + request as Parameters[0], + ) + case 'session/updateQueue': return sessionApi.updateQueue( + request as Parameters[0], + ) + case 'session/cancel': return sessionApi.cancel( + request as Parameters[0], + ) + case 'session/page': { + const page = request as FixturePageRequest + const pageSessionId = page.address.kind === 'session' + ? page.address.sessionId + : page.address.childSessionId + return sessionApi.history({ + sessionId: pageSessionId, + ...page.beforeSeq === undefined ? {} : { beforeSeq: page.beforeSeq }, + ...page.maxMessages === undefined ? {} : { maxMessages: page.maxMessages }, + }) + } + case '$events/result': return Promise.resolve(answerRemoteEvent(args as unknown as FixtureRemoteEventResult)) + case 'workspace/create': return workspaceApi.create(request as WorkspaceCreateRequest) + case 'workspace/rename': return workspaceApi.rename(request as WorkspaceRenameRequest) + case 'workspace/delete': return workspaceApi.delete(request as WorkspaceDeleteRequest) + case 'workspace/insertBefore': return workspaceApi.insertBefore(request as WorkspaceInsertBeforeRequest) + case 'workspace/insertSessionBefore': return workspaceApi.insertSessionBefore( + request as WorkspaceInsertSessionBeforeRequest, + ) + case 'workspace/archiveSession': return workspaceApi.archiveSession(request as WorkspaceArchiveSessionRequest) default: return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`)) } }, + open(channel, endpoint, payload, signal) { + if (channel !== '/api') { + throw new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`) + } + const args = (payload as { args: Readonly<{ request?: unknown }> }).args + switch (endpoint) { + case '$events': return openRemoteEvents(signal) + case 'session/control': return openControl(signal) + case 'session/follow': return openFollow(args.request as FixtureFollowRequest, signal) + case 'workspace/follow': return openWorkspace(signal) + default: + throw new Error(`fixture connection stream endpoint ${JSON.stringify(endpoint)} is unavailable`) + } + }, } return { api, rpc } } /** * Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it - * overrides the protocol-level virtuals (callUnary/openMux/openHost/respond) to dispatch - * straight into the in-memory ApiProxy — while still minting rpcIds, fabricating the four - * named full forms, and feeding the same tap as a real carrier. TODO: delete when the fixture + * overrides the legacy protocol-level call virtual to dispatch + * straight into the in-memory ApiProxy while still minting rpcIds, fabricating + * the request/response envelopes, and feeding the same tap as a real carrier. TODO: delete when the fixture * moves to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)). */ export class FixtureApiClient extends AbstractApiClient { @@ -3143,20 +3603,7 @@ export class FixtureApiClient extends AbstractApiClient { signal: AbortSignal, ): Promise> { switch (method) { - case 'session.list': return this.api.sessions.list(request) - case 'session.search': return this.api.sessions.search(request, signal) - case 'session.create': return this.api.sessions.create(request) - case 'session.history': return this.api.sessions.history(request) - case 'session.models': return this.api.sessions.models(request) - case 'session.selectModel': return this.api.sessions.selectModel(request) - case 'session.rename': return this.api.sessions.rename(request) - case 'session.fork': return this.api.sessions.fork(request) - case 'session.prompt': return this.api.sessions.prompt(request) - case 'session.attachment': return this.api.sessions.attachment(request) - case 'session.updateQueue': return this.api.sessions.updateQueue(request) - case 'session.cancel': return this.api.sessions.cancel(request) case 'subagent.list': return this.api.subagents.list(request) - case 'subagent.history': return this.api.subagents.history(request) case 'subagent.prompt': return this.api.subagents.prompt(request, signal) case 'subagent.interrupt': return this.api.subagents.interrupt(request) case 'host.describe': return this.api.host.describe(request) @@ -3164,13 +3611,6 @@ export class FixtureApiClient extends AbstractApiClient { case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) - case 'workspace.list': return this.api.workspace.list(request) - case 'workspace.create': return this.api.workspace.create(request) - case 'workspace.rename': return this.api.workspace.rename(request) - case 'workspace.delete': return this.api.workspace.delete(request) - case 'workspace.insertBefore': return this.api.workspace.insertBefore(request) - case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) - case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'skill.list': return this.api.skills.list(request) case 'agentPreset.list': return this.api.agentPresets.list(request) case 'agentPreset.select': return this.api.agentPresets.select(request) @@ -3198,46 +3638,6 @@ export class FixtureApiClient extends AbstractApiClient { } } - protected override openMux( - payload: { since?: Record }, - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.tapStream(this.api.events.mux(rpcRequest(payload), signal), onOpen) - } - - protected override openHost( - payload: Record, - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.tapStream(this.api.events.host(rpcRequest(payload), signal), onOpen) - } - - private async *tapStream( - stream: AsyncIterable>, - onOpen?: () => void, - ): AsyncGenerator> { - // No HTTP here: the in-memory stream is established the moment iteration starts (mirrors - // readSse firing onOpen after response headers, before any frame). - onOpen?.() - for await (const envelope of stream) { - const full: ServerRequest = { type: 'server-request', rpcId: envelope.rpcId, method: envelope.payload.type, payload: envelope.payload } - this.onEnvelope(full) - yield envelope - } - } - - /** - * Deliver a client response to the in-memory contract impl (no HTTP POST), - * echoing the envelope to the observation tap like every other path. - * @param message - the client-response envelope answering a server request. - * @returns the carrier receipt from the fixture impl. - */ - override async respond(message: ClientResponse): Promise { - this.onEnvelope(message) - return this.api.respond(message) - } } /** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */ diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 9631881505..e21ab8388f 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -1,30 +1,44 @@ /** * Browser wire client. The plugin selects fixture or HTTP transport, provides - * the shared API client, and lets the runtime object layer start the stream - * controller with its sinks. + * the shared API client, and lets API Gateway own the connection loop. */ import type { Context } from '@deepseek-ai/cordis' import type { HostDescription, IApiClient } from './api.ts' -import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' +import { + ConnectionController, + type ConnectionConfig, + type ConnectionGenerationSource, + type ConnectionSinks, + type ConnectionState, +} from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' -import { createWebConnectionRpc, type RpcFetch } from './rpc.ts' +import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * A connection generation was established. Wire-derived caches must + * repull; long-lived streams own their own resume and baseline lifecycle. + * @mode emit + */ + 'connection/reset'(): void + } +} + // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { - ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApiProxy, HostApi, DirectoryEntry, DirectoryListing, - ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, + ToolCallView, ToolResultView, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, + MessageId, ModelReasoningEffort, ModelSelection, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, - JobView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, - ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, + ClientRequest, ServerResponse, RpcMessage, HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, GoalsApi, GoalRef, SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, @@ -38,8 +52,10 @@ export { // Connection loop types are public through ConnectionHandle.start; the // controller remains package-internal. -export type { ConnectionConfig, ConnectionSinks, ConnectionState } -export type { ClientConnectionRpc } from '../rpc.ts' +export type { ConnectionConfig, ConnectionGenerationSource, ConnectionSinks, ConnectionState } +export type { + ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, +} from '../rpc.ts' export type { RpcFetch } from './rpc.ts' /** Observable Host description published by each completed connection handshake. */ @@ -64,6 +80,8 @@ export interface ClientTransportHooks { createApiClient(): IApiClient /** Transport for generic unary RPC channels (the Typert gateway). */ fetch: RpcFetch + /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */ + openStream?: RpcStreamOpen /** * Bundle transport for the module system, present when the carrier also owns * bundle bytes (the worker tunnel). Absent in the served web app, whose @@ -87,9 +105,9 @@ interface ClientTransportGlobal { } /** - * The ctx.connection service API: the API client plus a one-shot - * controller starter (the runtime plugin supplies sinks when its object layer - * is ready — connection stays consumer-agnostic). + * The ctx.connection service API: the API client plus a one-shot controller + * starter. API Gateway supplies generation readiness and reset callbacks; + * Connection stays independent of downstream domain state. */ export interface ConnectionHandle { /** Shared api client (fixture or real, decided at boot from the page URL). */ @@ -105,10 +123,16 @@ export interface ConnectionHandle { /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc /** - * Start the connect/pump/reconnect loop with the consumer's frame sinks. - * One consumer owns the streams (the runtime object layer); a second call - * throws. - * @param sinks - frame/state callbacks. + * Register the sole source defining Host generations. The source reports + * ready only after its incremental listeners are attached. + * @param source - long-lived generation source owned by the push carrier. + * @returns disposer withdrawing the source and stopping an active loop. + */ + registerGenerationSource(source: ConnectionGenerationSource): () => void + /** + * Start the connect/reconnect loop with the consumer's state callbacks. + * API Gateway owns the loop; a second call throws. + * @param sinks - connection-state callbacks. * @param config - reconnect/backoff tunables. * @returns stop handle for the loop. */ @@ -125,8 +149,10 @@ export function apply(ctx: Context): void { const fixtureClient = fixture ? new FixtureApiClient() : undefined const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient() - const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch) + const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) let started = false + let generationSource: ConnectionGenerationSource | undefined + let controller: ConnectionController | undefined let description: HostDescription | undefined const descriptionListeners = new Set<() => void>() const publishDescription = (next: HostDescription | undefined): void => { @@ -136,7 +162,7 @@ export function apply(ctx: Context): void { try { listener() } catch (error) { - console.error('[web-runtime] host-description listener threw:', error) + console.error('[connection] host-description listener threw:', error) } } } @@ -151,10 +177,23 @@ export function apply(ctx: Context): void { }, }, rpc, + registerGenerationSource(source) { + if (generationSource !== undefined) { + throw new Error('connection: a generation source is already registered') + } + generationSource = source + return () => { + if (generationSource !== source) return + generationSource = undefined + controller?.stop() + publishDescription(undefined) + } + }, start(sinks, config) { if (started) throw new Error('connection: the stream loop is already owned by another consumer') + if (generationSource === undefined) throw new Error('connection: no generation source is registered') started = true - const controller = new ConnectionController(api, { + controller = new ConnectionController(api, generationSource, { ...sinks, onConnected: (next) => { publishDescription(next) @@ -173,7 +212,7 @@ export function apply(ctx: Context): void { controller.start() return { stop: () => { - controller.stop() + controller?.stop() publishDescription(undefined) }, } diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 8781b3ee34..c7b609c01b 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -2,10 +2,10 @@ import { RpcId, - serverResponseSchema, type ClientRequest, + type RpcId as RpcIdType, } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { ClientConnectionRpc } from '../rpc.ts' +import type { ClientConnectionRpc, ConnectionRpcResult } from '../rpc.ts' import { randomUuid } from './random-uuid.ts' const INTERNAL_BASE = 'http://dsh.internal' @@ -15,12 +15,20 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ /** Transport this caller posts through; same signature as the global `fetch`. */ export type RpcFetch = (input: URL, init: RequestInit) => Promise +/** Worker-local opener for decoded Gateway Remote streams. */ +export type RpcStreamOpen = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => AsyncIterable + /** * Create the browser-backed generic RPC caller. * @param doFetch - transport override; defaults to the page's global fetch. + * @param openStream - optional worker-local Gateway stream carrier. * @returns caller that owns request correlation and response-envelope validation. */ -export function createWebConnectionRpc(doFetch?: RpcFetch): ClientConnectionRpc { +export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStreamOpen): ClientConnectionRpc { const send: RpcFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init)) return { async call(channel, endpoint, payload, signal) { @@ -44,15 +52,59 @@ export function createWebConnectionRpc(doFetch?: RpcFetch): ClientConnectionRpc if (!response.ok) { throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`) } - const full = serverResponseSchema.parse(await response.json()) + const full = parseConnectionResponse(await response.json()) if (full.rpcId !== rpcId) { throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`) } return full.result }, + ...openStream === undefined ? {} : { + open(channel, endpoint, payload, signal) { + assertTarget(channel, endpoint) + if (channel !== '/api') { + throw new Error(`connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`) + } + return openStream(endpoint, payload, signal) + }, + }, } } +function parseConnectionResponse(value: unknown): { + readonly rpcId: RpcIdType + readonly result: ConnectionRpcResult +} { + if (!isRecord(value) || value.type !== 'server-response' || typeof value.rpcId !== 'string') { + throw new TypeError('connection: invalid server-response envelope') + } + const result = value.result + if (!isRecord(result)) throw new TypeError('connection: invalid server-response result') + if (result.ok === true) { + return { + rpcId: RpcId(value.rpcId), + result: { ok: true, value: result.value }, + } + } + if (result.ok !== false || !isRecord(result.error)) { + throw new TypeError('connection: invalid server-response result') + } + const error = result.error + if (typeof error.code !== 'string' || typeof error.message !== 'string' || !isRecord(error.details)) { + throw new TypeError('connection: invalid server-response failure') + } + return { + rpcId: RpcId(value.rpcId), + result: { + ok: false, + error: { code: error.code, message: error.message, details: error.details }, + }, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function resolveBase(): string { const location = (globalThis as { location?: { origin?: string } }).location return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE diff --git a/packages/client/connection/src/client/web-api-client.ts b/packages/client/connection/src/client/web-api-client.ts index a2c2d95b7b..6716f252a5 100644 --- a/packages/client/connection/src/client/web-api-client.ts +++ b/packages/client/connection/src/client/web-api-client.ts @@ -1,91 +1,10 @@ -/** Browser API carrier: HTTP upstream plus one WebSocket per downstream event stream. */ +/** Browser API carrier for unary HTTP calls. */ -import type { ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest } from './api.ts' import { AbstractApiClient } from './api.ts' -import { hostFrameSchema, muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema' -import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema' -import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../api-path.ts' -type SocketItem = { kind: 'frame'; envelope: RpcRequest } | { kind: 'end' } -type Parser = { parse(value: unknown): F } - -/** Browser platform subclass: unary/respond use fetch; mux/host use downlink-only WebSockets. */ +/** Browser platform subclass supplying fetch for unary calls. */ export class WebApiClient extends AbstractApiClient { protected doFetch(input: URL, init?: RequestInit): Promise { return globalThis.fetch(input, init) } - - protected override openMux( - _payload: Parameters[0]['payload'], - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.readWebSocket(MUX_EVENTS_PATH, signal, muxFrameSchema, onOpen) - } - - protected override openHost( - _payload: Parameters[0]['payload'], - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.readWebSocket(HOST_EVENTS_PATH, signal, hostFrameSchema, onOpen) - } - - private async *readWebSocket( - path: string, - signal: AbortSignal, - frameSchema: Parser, - onOpen?: () => void, - ): AsyncGenerator> { - const url = new URL(path, this.resolveBase()) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - const socket = new WebSocket(url) - const inbox: SocketItem[] = [] - let wake: (() => void) | undefined - const enqueue = (item: SocketItem): void => { - inbox.push(item) - wake?.() - wake = undefined - } - const handleOpen = (): void => { onOpen?.() } - const handleMessage = (event: MessageEvent): void => { - let full: ServerRequest - let frame: F - try { - if (typeof event.data !== 'string') throw new Error('binary WebSocket frame') - full = serverRequestSchema.parse(JSON.parse(event.data)) - frame = frameSchema.parse(full.payload) - } catch (error) { - console.error(`[client-connection] dropping malformed WebSocket frame on ${path}:`, error) - return - } - this.onEnvelope(full) - enqueue({ kind: 'frame', envelope: { rpcId: full.rpcId, payload: frame } }) - } - const handleClose = (): void => { enqueue({ kind: 'end' }) } - const handleAbort = (): void => { - if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) socket.close() - } - socket.addEventListener('open', handleOpen) - socket.addEventListener('message', handleMessage) - socket.addEventListener('close', handleClose, { once: true }) - signal.addEventListener('abort', handleAbort, { once: true }) - if (signal.aborted) handleAbort() - try { - while (true) { - while (inbox.length > 0) { - const item = inbox.shift() as SocketItem - if (item.kind === 'end') return - yield item.envelope - } - await new Promise((resolve) => { wake = resolve }) - } - } finally { - signal.removeEventListener('abort', handleAbort) - socket.removeEventListener('open', handleOpen) - socket.removeEventListener('message', handleMessage) - socket.removeEventListener('close', handleClose) - handleAbort() - } - } } diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 07fc0fc5da..b404e65103 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -23,7 +23,7 @@ export interface FetchHandler { /** * Bridge one node:http request to the fetch-shaped handler (client close - * aborts; SSE bodies stream out chunk by chunk). + * aborts; response bodies stream out chunk by chunk). * @param req - incoming node:http request (fully read before dispatch). * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. @@ -38,8 +38,8 @@ export async function bridge( const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: // since Node 16, IncomingMessage 'close' fires as soon as the request body is - // fully consumed (immediately for a bodyless GET), which would abort every SSE - // stream right after open. ServerResponse 'close' fires on connection teardown; + // fully consumed (immediately for a bodyless GET), which would abort a + // streaming response right after open. ServerResponse 'close' fires on connection teardown; // writableEnded distinguishes a normal end() from the client going away. res.on('close', () => { if (!res.writableEnded) abort.abort() @@ -80,7 +80,7 @@ export async function bridge( } for await (const chunk of response.body) { // Backpressure: a false return means the socket buffer is full — wait for drain - // instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also + // instead of buffering unboundedly (slow or suspended consumers). 'close' also // resolves so a mid-wait disconnect can't park this loop forever; the close // handler above aborts the handler stream, which then ends the iteration. if (!res.write(chunk)) { diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index a1764a3d58..1944e09722 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -3,25 +3,27 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-attachment' // Activates the webServer Context merge used below. -import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' +import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' +import { API_PATH } from './api-path.ts' import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' import { HostConnectionService } from './rpc-host.ts' -import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts' export type { ConnectionRpcAuthority, ConnectionRpcEndpointMatcher, + ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRpcHandlerOptions, + ConnectionRpcResult, + ConnectionTrustRequest, HostConnectionHandle, HostConnectionRpc, } from './rpc.ts' export { HostConnectionService } from './rpc-host.ts' -export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' +export { API_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' @@ -147,12 +149,6 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { && !isTrustedApiRequest(request, [])) { return new Response('forbidden', { status: 403 }) } - if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { - return new Response('upgrade required', { - status: 426, - headers: { connection: 'Upgrade', upgrade: 'websocket' }, - }) - } const apiProxy = ctx.get('apiProxy') if (apiProxy === undefined) return new Response('not found', { status: 404 }) return toFetchHandler(apiProxy).fetch(request) @@ -171,26 +167,5 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { }, } ctx.effect(() => ctx.webServer.register(route), 'client-connection: /api route') - ctx.inject(['apiProxy'], (apiCtx) => { - assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) - const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) - const registerDownlink = ( - path: string, - handle: WebUpgradeRoute['handler'], - ): void => { - apiCtx.effect(() => apiCtx.webServer.registerUpgrade({ - path, - handler: (req, socket, head) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - rejectWebSocketUpgrade(socket) - return - } - return handle(req, socket, head) - }, - }), `client-connection: ${path} WebSocket`) - } - apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') - registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) - registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) - }) + ctx.inject(['apiProxy'], (apiCtx) => { assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) }) } diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 0da66c85a7..162045ed0d 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -9,7 +9,6 @@ import { type RpcError, type RpcErrorDetailsMap, type RpcId as RpcIdType, - type ServerResponse as RpcServerResponse, } from '@deepseek-ai/dsh-host-apiproxy/api' import { bridge, type FetchHandler } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' @@ -18,6 +17,9 @@ import type { ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcHandlerOptions, + ConnectionRpcResult, + ConnectionRpcAuthority, + ConnectionTrustRequest, HostConnectionHandle, HostConnectionRpc, } from './rpc.ts' @@ -32,6 +34,12 @@ interface ConnectionRpcInterceptor { readonly options: ConnectionRpcHandlerOptions } +interface ConnectionServerResponse { + readonly type: 'server-response' + readonly rpcId: RpcIdType + readonly result: ConnectionRpcResult +} + declare module '@deepseek-ai/cordis' { interface Context { /** Host Connection transport and RPC registrations. */ @@ -62,6 +70,11 @@ export class HostConnectionService extends Service implements HostConnectionHand } } + /** Apply the existing configured request trust policy to a sibling Web route. */ + isTrustedRequest(request: ConnectionTrustRequest, authority: ConnectionRpcAuthority): boolean { + return isTrustedApiRequest(request, authority === 'loopback' ? [] : this.trustedHosts) + } + /** * Compose one shared-channel Fetch handler from its interceptor and fallback. * @param channel - shared channel mounted by Connection. @@ -212,8 +225,8 @@ function errorResponse(rpcId: RpcIdType, error: RpcError): Response { return fullResponse(rpcId, { ok: false, error }) } -function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response { - const body: RpcServerResponse = { type: 'server-response', rpcId, result } +function fullResponse(rpcId: RpcIdType, result: ConnectionRpcResult): Response { + const body: ConnectionServerResponse = { type: 'server-response', rpcId, result } return Response.json(body) } diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index e1260f00e8..e8dc585d38 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,6 +1,22 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ -import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' +/** Carrier-neutral failure returned by one logical RPC endpoint. */ +export interface ConnectionRpcFailure { + readonly code: string + readonly message: string + readonly details: object +} + +/** Carrier-neutral result returned by one logical RPC endpoint. */ +export type ConnectionRpcResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: ConnectionRpcFailure } + +/** HTTP request facts consumed by the existing browser trust fence. */ +export interface ConnectionTrustRequest { + /** Request headers supplied by either the Fetch or node:http representation. */ + readonly headers: Headers | Readonly> +} /** Trust fence applied before a Host RPC channel reaches its handler. */ export type ConnectionRpcAuthority = 'trusted-host' | 'loopback' @@ -16,7 +32,7 @@ export type ConnectionRpcHandler = ( endpoint: string, payload: unknown, signal: AbortSignal, -) => Promise> +) => Promise> /** Synchronous ownership test for one endpoint on a shared RPC channel. */ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean @@ -56,6 +72,14 @@ export interface HostConnectionRpc { export interface HostConnectionHandle { /** Generic RPC channel registry. */ readonly rpc: HostConnectionRpc + + /** + * Apply Connection's configured browser trust policy to another Web route. + * @param request - request headers from the HTTP or upgrade request. + * @param authority - configured trusted hosts or loopback-only policy. + * @returns whether the route may accept the request. + */ + isTrustedRequest(request: ConnectionTrustRequest, authority: ConnectionRpcAuthority): boolean } /** Client caller for logical RPC channels carried by the current transport. */ @@ -66,12 +90,28 @@ export interface ClientConnectionRpc { * @param endpoint - channel-relative endpoint such as `goals/create`. * @param payload - channel-owned request payload. * @param signal - optional caller cancellation. - * @returns the existing RPC success/error result; correlation stays inside Connection. + * @returns the endpoint-owned success/error result; correlation stays inside Connection. */ call( channel: string, endpoint: string, payload: unknown, signal?: AbortSignal, - ): Promise> + ): Promise> + + /** + * Open an in-process logical stream when the selected carrier supplies one. + * Browser transports omit this method; API Gateway owns their WebSocket mux. + * @param channel - absolute logical channel such as `/api`. + * @param endpoint - channel-relative endpoint such as `session/follow`. + * @param payload - channel-owned request payload. + * @param signal - caller cancellation for this logical stream. + * @returns decoded stream values from the in-process carrier. + */ + readonly open?: ( + channel: string, + endpoint: string, + payload: unknown, + signal: AbortSignal, + ) => AsyncIterable } diff --git a/packages/client/connection/src/websocket-downlink.ts b/packages/client/connection/src/websocket-downlink.ts deleted file mode 100644 index 72ae5e94ef..0000000000 --- a/packages/client/connection/src/websocket-downlink.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** Host-side WebSocket carrier for the two server-to-browser event streams. */ - -import { randomUUID } from 'node:crypto' -import type { IncomingMessage } from 'node:http' -import type { Duplex } from 'node:stream' -import WebSocket, { WebSocketServer } from 'ws' -import type { - ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest, -} from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' - -type Frame = MuxFrame | HostFrame - -function serverRequest(frame: RpcRequest): ServerRequest { - return { - type: 'server-request', - rpcId: frame.rpcId, - method: frame.payload.type, - payload: frame.payload, - } -} - -function send(socket: WebSocket, frame: RpcRequest): Promise { - return new Promise((resolve, reject) => { - if (socket.readyState !== WebSocket.OPEN) { - reject(new Error('websocket downlink closed before frame delivery')) - return - } - socket.send(JSON.stringify(serverRequest(frame)), (error) => { - if (error) reject(error) - else resolve() - }) - }) -} - -function failureFrame(error: unknown): RpcRequest { - return { - rpcId: RpcId(randomUUID()), - payload: { - type: 'stream/error', - error: { code: 'internal', message: String(error), details: {} }, - }, - } -} - -/** - * Owns WebSocket negotiation and frame pumping for the connection plugin's - * two downlinks. Client messages are a protocol violation: upstream traffic - * remains on HTTP. - */ -export class WebSocketDownlinks { - private readonly server = new WebSocketServer({ noServer: true }) - private readonly pumps = new Set>() - - /** @param api - host API supplying the typed event streams. */ - constructor(private readonly api: ApiProxy) {} - - /** - * Upgrade one socket and pump the mux stream until either side closes. - * @param req - HTTP upgrade request. - * @param socket - Raw socket transferred by the HTTP server. - * @param head - Bytes already read after the upgrade headers. - */ - handleMux(req: IncomingMessage, socket: Duplex, head: Buffer): void { - this.upgrade(req, socket, head, signal => this.api.events.mux({ - rpcId: RpcId(randomUUID()), - payload: {}, - }, signal)) - } - - /** - * Upgrade one socket and pump the host stream until either side closes. - * @param req - HTTP upgrade request. - * @param socket - Raw socket transferred by the HTTP server. - * @param head - Bytes already read after the upgrade headers. - */ - handleHost(req: IncomingMessage, socket: Duplex, head: Buffer): void { - this.upgrade(req, socket, head, signal => this.api.events.host({ - rpcId: RpcId(randomUUID()), - payload: {}, - }, signal)) - } - - /** - * Terminate owned sockets and await the no-server acceptor plus frame pumps. - * @returns A promise resolving after every socket and source iterator stops. - */ - async close(): Promise { - for (const socket of this.server.clients) socket.terminate() - await new Promise((resolve, reject) => { - this.server.close((error) => { - if (error === undefined) resolve() - else reject(error) - }) - }) - await Promise.all(this.pumps) - } - - private upgrade( - req: IncomingMessage, - socket: Duplex, - head: Buffer, - open: (signal: AbortSignal) => AsyncIterable>, - ): void { - this.server.handleUpgrade(req, socket, head, (websocket) => { - const abort = new AbortController() - websocket.once('close', () => { abort.abort() }) - websocket.once('error', () => { abort.abort() }) - websocket.once('message', () => { - websocket.close(1008, 'downlink only') - }) - const pump = this.pump(websocket, open(abort.signal), abort) - this.pumps.add(pump) - void pump.then(() => { this.pumps.delete(pump) }) - }) - } - - private async pump( - socket: WebSocket, - frames: AsyncIterable>, - abort: AbortController, - ): Promise { - try { - for await (const frame of frames) await send(socket, frame) - } catch (error) { - if (!abort.signal.aborted) { - try { - await send(socket, failureFrame(error)) - } catch { - // Socket loss won the race; no downstream remains to receive the failure frame. - } - } - } finally { - abort.abort() - if (socket.readyState === WebSocket.OPEN) socket.close() - } - } -} - -/** - * Reject an untrusted upgrade before protocol negotiation. - * @param socket - Raw HTTP socket that remains owned by the caller. - */ -export function rejectWebSocketUpgrade(socket: Duplex): void { - socket.end([ - 'HTTP/1.1 403 Forbidden', - 'Connection: close', - 'Content-Type: text/plain; charset=utf-8', - 'Content-Length: 9', - '', - 'forbidden', - ].join('\r\n')) -} diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index b7f6ebe389..97fd176ef0 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -1,59 +1,57 @@ /** * Connection plugin browser-half apply: ctx.connection handle mounting, mode - * selection off the page URL, and the single-consumer stream-loop ownership. + * selection off the page URL, and single-consumer connection-loop ownership. */ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { apply, type ConnectionHandle } from '../src/client/index.ts' -import type { RpcMessage } from '../src/client/api.ts' -import { RpcId } from '../src/client/api.ts' +import { + apply, + type ClientTransportHooks, + type ConnectionGenerationSource, + type ConnectionHandle, +} from '../src/client/index.ts' import { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { hostname: string; search: string; origin?: string } } -type WebSocketGlobal = { WebSocket?: typeof WebSocket } - -const originalWebSocket = globalThis.WebSocket -const sockets: FakeWebSocket[] = [] - -class FakeWebSocket extends EventTarget { - static readonly CONNECTING = 0 - static readonly OPEN = 1 - static readonly CLOSING = 2 - static readonly CLOSED = 3 - - readonly url: string - readyState = FakeWebSocket.CONNECTING - - constructor(url: string | URL) { - super() - this.url = String(url) - sockets.push(this) - queueMicrotask(() => { - if (this.readyState !== FakeWebSocket.CONNECTING) return - this.readyState = FakeWebSocket.OPEN - this.dispatchEvent(new Event('open')) - }) - } - - close(): void { - if (this.readyState === FakeWebSocket.CLOSED) return - this.readyState = FakeWebSocket.CLOSED - this.dispatchEvent(new Event('close')) - } - - receive(data: unknown): void { - this.dispatchEvent(new MessageEvent('message', { data })) - } +type Win = { + location?: { hostname: string; search: string; origin?: string } + __DSH_TRANSPORT__?: ClientTransportHooks } afterEach(() => { delete (globalThis as Win).location - sockets.length = 0 - if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket - else globalThis.WebSocket = originalWebSocket + delete (globalThis as Win).__DSH_TRANSPORT__ }) +class GenerationProbe { + private readonly active = new Set<() => void>() + + readonly source: ConnectionGenerationSource = (signal, ready) => new Promise((resolve) => { + let settled = false + const finish = (): void => { + if (settled) return + settled = true + signal.removeEventListener('abort', finish) + this.active.delete(finish) + resolve() + } + this.active.add(finish) + signal.addEventListener('abort', finish, { once: true }) + ready() + if (signal.aborted) finish() + }) + + end(): void { + for (const finish of [...this.active]) finish() + } +} + +function installGeneration(handle: ConnectionHandle): GenerationProbe { + const probe = new GenerationProbe() + handle.registerGenerationSource(probe.source) + return probe +} + async function mount(): Promise { const ctx = new Context() await ctx.plugin({ apply, inject: [] }) @@ -84,9 +82,33 @@ describe('connection client apply', () => { expect((await mount()).isLoopback).toBe(false) }) - it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => { + it('requires one generation source and ignores a stale source disposer', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() + const first = new GenerationProbe() + const second = new GenerationProbe() + + expect(() => handle.start({})).toThrow('no generation source is registered') + const unregisterFirst = handle.registerGenerationSource(first.source) + expect(() => { handle.registerGenerationSource(second.source) }) + .toThrow('a generation source is already registered') + unregisterFirst() + const unregisterSecond = handle.registerGenerationSource(second.source) + unregisterFirst() + + const loop = handle.start({}) + await vi.waitFor(() => { + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + }) + unregisterSecond() + expect(handle.hostDescription.getSnapshot()).toBeUndefined() + loop.stop() + }) + + it('start() hands out one loop, rejects a second consumer, and stop() aborts the generation', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + installGeneration(handle) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const descriptions: Array = [] const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') }) @@ -114,6 +136,7 @@ describe('connection client apply', () => { it('does not announce a generation synchronously stopped by a description subscriber', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() + installGeneration(handle) const owner: { loop?: ReturnType } = {} let sawDescription = false const stopDescription = handle.hostDescription.subscribe(() => { @@ -137,6 +160,7 @@ describe('connection client apply', () => { it('retracts the host description while reconnecting and republishes the next generation', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() + const generation = installGeneration(handle) const descriptions: Array = [] const reconnectSnapshots: Array = [] const stopDescription = handle.hostDescription.subscribe(() => { @@ -149,16 +173,12 @@ describe('connection client apply', () => { reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath) } }, - }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 }) + }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) try { await vi.waitFor(() => { expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) }) - const timing = (globalThis as Record).__fxTiming as - | { breakStreams(): void } - | undefined - if (timing === undefined) throw new Error('fixture timing hooks missing') - timing.breakStreams() + generation.end() await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) }) await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) }) @@ -170,7 +190,7 @@ describe('connection client apply', () => { } }) - it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => { + it('WebApiClient keeps unary calls on globalThis.fetch', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch @@ -182,103 +202,10 @@ describe('connection client apply', () => { try { // Schema rejection is fine — the transport hop is the assertion. await (handle.api as WebApiClient).host.describe({}).catch(() => undefined) - await handle.api.respond({ - type: 'client-response', - rpcId: RpcId('response-over-http'), - result: { ok: true, value: {} }, - }).catch(() => undefined) } finally { globalThis.fetch = original } expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true) - expect(seen.some(u => u.includes('/api/respond'))).toBe(true) - }) - - it('opens one WebSocket per downlink, parses frames, and aborts both without using fetch', async () => { - ;(globalThis as Win).location = { - hostname: 'localhost', search: '', origin: 'http://localhost:3080', - } - ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket - const fetch = vi.spyOn(globalThis, 'fetch') - const client = (await mount()).api as WebApiClient - const envelopes: RpcMessage[][] = [] - client.subscribeEnvelopes((batch) => { envelopes.push([...batch]) }) - const opened: string[] = [] - const muxAbort = new AbortController() - const hostAbort = new AbortController() - const mux = client.events.mux({}, muxAbort.signal, () => { opened.push('mux') })[Symbol.asyncIterator]() - const host = client.events.host({}, hostAbort.signal, () => { opened.push('host') })[Symbol.asyncIterator]() - const muxFrame = mux.next() - const hostFrame = host.next() - await vi.waitFor(() => { expect(sockets).toHaveLength(2) }) - expect(sockets.map(socket => socket.url)).toEqual([ - 'ws://localhost:3080/api/events.mux', - 'ws://localhost:3080/api/events.host', - ]) - await vi.waitFor(() => { expect(opened).toEqual(['mux', 'host']) }) - - const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) - sockets[0]!.receive(new Uint8Array([1, 2, 3])) - sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'bad', method: 'host/session-status', payload: {} })) - sockets[0]!.receive(JSON.stringify({ - type: 'server-request', - rpcId: 'mux-browser', - method: 'session/subscribed', - payload: { type: 'session/subscribed', sessionId: 'session-browser', lastSeq: 8 }, - })) - sockets[1]!.receive(JSON.stringify({ - type: 'server-request', - rpcId: 'host-browser', - method: 'host/remote-event', - payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, - })) - expect(await muxFrame).toMatchObject({ - value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } }, - }) - expect(await hostFrame).toMatchObject({ - value: { rpcId: 'host-browser', payload: { type: 'host/remote-event', event: 'commands/change' } }, - }) - expect(errors).toHaveBeenCalledTimes(2) - await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) }) - expect(fetch).not.toHaveBeenCalled() - - const muxEnd = mux.next() - const hostEnd = host.next() - muxAbort.abort() - hostAbort.abort() - await expect(muxEnd).resolves.toMatchObject({ done: true }) - await expect(hostEnd).resolves.toMatchObject({ done: true }) - expect(sockets.every(socket => socket.readyState === FakeWebSocket.CLOSED)).toBe(true) - errors.mockRestore() - fetch.mockRestore() - }) - - it('maps an HTTPS page origin to a secure WebSocket URL', async () => { - ;(globalThis as Win).location = { - hostname: 'harness.example', search: '', origin: 'https://harness.example', - } - ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket - const client = (await mount()).api - const abort = new AbortController() - const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]() - const pending = iterator.next() - await vi.waitFor(() => { expect(sockets[0]?.url).toBe('wss://harness.example/api/events.mux') }) - abort.abort() - await expect(pending).resolves.toMatchObject({ done: true }) - }) - - it('closes a WebSocket immediately when its signal was already aborted', async () => { - ;(globalThis as Win).location = { - hostname: 'localhost', search: '', origin: 'http://localhost:3080', - } - ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket - const client = (await mount()).api - const abort = new AbortController() - abort.abort() - const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ done: true }) - expect(sockets).toHaveLength(1) - expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) it('carries RPC calls without requiring secure-context randomUUID', async () => { @@ -319,6 +246,44 @@ describe('connection client apply', () => { }) }) + it('exposes a worker-local Gateway stream through connection.rpc.open', async () => { + ;(globalThis as Win).location = { hostname: 'preview.example', search: '' } + const openStream = vi.fn>( + (endpoint, payload, signal) => (async function *(): AsyncGenerator { + signal.throwIfAborted() + yield { endpoint, payload } + })(), + ) + ;(globalThis as Win).__DSH_TRANSPORT__ = { + createApiClient: () => new FixtureApiClient(), + fetch: vi.fn(), + openStream, + ownsHost: true, + } + const handle = await mount() + const abort = new AbortController() + const open = handle.rpc.open + if (open === undefined) throw new Error('worker-local stream carrier was not installed') + + const values = [] + for await (const value of open('/api', 'session/follow', { args: { sessionId: 'session-1' } }, abort.signal)) { + values.push(value) + } + expect(values).toEqual([{ + endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } }, + }]) + expect(openStream).toHaveBeenCalledWith( + 'session/follow', + { args: { sessionId: 'session-1' } }, + abort.signal, + ) + expect(handle.isLoopback).toBe(true) + expect(() => open('/rpc', 'session/follow', {}, abort.signal)) + .toThrow('worker-local streams require the /api channel') + expect(() => open('/api/path', 'session/follow', {}, abort.signal)) + .toThrow('invalid RPC target') + }) + it('validates generic RPC transport failures, correlation, and targets', async () => { ;(globalThis as Win).location = { hostname: 'harness.example', search: '', origin: 'https://harness.example', @@ -345,6 +310,51 @@ describe('connection client apply', () => { const fetch = vi.mocked(globalThis.fetch) expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create')) expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal') + + const respond = (result: unknown): void => { + globalThis.fetch = async (_input: URL | RequestInfo, init?: RequestInit) => { + if (typeof init?.body !== 'string') throw new TypeError('expected a JSON request body') + const request = JSON.parse(init.body) as { rpcId: string } + return Response.json({ type: 'server-response', rpcId: request.rpcId, result }) + } + } + for (const envelope of [ + null, + { type: 'other', rpcId: 'rpc', result: { ok: true } }, + { type: 'server-response', rpcId: 1, result: { ok: true } }, + ]) { + globalThis.fetch = vi.fn().mockResolvedValue(Response.json(envelope)) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response envelope') + } + + respond(null) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response result') + respond({ ok: 'yes' }) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response result') + respond({ ok: false, error: null }) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response result') + + for (const error of [ + { code: 1, message: 'failed', details: {} }, + { code: 'failed', message: 1, details: {} }, + { code: 'failed', message: 'failed', details: [] }, + ]) { + respond({ ok: false, error }) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response failure') + } + respond({ + ok: false, + error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } }, + }) + await expect(handle.rpc.call('/api', 'goals/create', {})).resolves.toEqual({ + ok: false, + error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } }, + }) } finally { globalThis.fetch = original } diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 7965d627f4..03f57f9273 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -1,32 +1,24 @@ /** - * ConnectionController: stream pumping into sinks, the strict readiness - * handshake (describe + both streams' onOpen, timeout-guarded), generation + * ConnectionController: strict readiness handshake (describe + incremental + * source ready), generation * abort on loss, backoff reconnection, state transitions, and sink-exception * isolation. Real (short) timers — the timeout and backoff are configurable, * so tests run them at millisecond scale. */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '../src/client/api.ts' import type { ConnectionState } from '../src/client/connection.ts' import { ConnectionController } from '../src/client/connection.ts' import { FakeApiClient, deferred, ok } from './fake-api.client.ts' -const SID = 'fk-c1' as SessionId -const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 } - -function subscribedFrame(lastSeq = 0) { - return { type: 'session/subscribed', sessionId: SID, lastSeq } as const -} +const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 } describe('connection lifecycle', () => { - it('announces connected after describe + both streams open, then pumps frames to sinks', async () => { + it('announces connected after describe plus generation readiness', async () => { const api = new FakeApiClient() - const muxSeen: string[] = [] const descriptions: boolean[] = [] let connected = 0 - const controller = new ConnectionController(api, { - onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type), + const controller = new ConnectionController(api, api.generation, { onConnected: (description) => { connected++ descriptions.push(description.canOpenPath) @@ -35,8 +27,6 @@ describe('connection lifecycle', () => { controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - api.pushMux(subscribedFrame()) - await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) }) expect(api.callsOf('host.describe')).toHaveLength(1) expect(descriptions).toEqual([true]) } finally { @@ -44,25 +34,25 @@ describe('connection lifecycle', () => { } }) - it('reconnects with a fresh generation when a stream fails, and stop() ends the loop', async () => { + it('reconnects with a fresh generation when its source fails, and stop() ends the loop', async () => { const api = new FakeApiClient() let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) api.failStreams(new Error('stream torn')) await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff - expect(api.openMuxCount).toBe(1) // the dead generation's stream is gone, exactly one live + expect(api.openGenerationCount).toBe(1) } finally { controller.stop() warnSpy.mockRestore() } - // stop() aborts the live generation (streams tear down) and no reconnect follows. - await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) }) + // stop() aborts the live generation and no reconnect follows. + await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) await new Promise(resolve => setTimeout(resolve, 40)) - expect(api.openMuxCount).toBe(0) + expect(api.openGenerationCount).toBe(0) }) it('treats describe failure as generation failure and retries', async () => { @@ -75,7 +65,7 @@ describe('connection lifecycle', () => { } let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff @@ -106,7 +96,7 @@ describe('connection lifecycle', () => { } let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(2) }) @@ -117,70 +107,45 @@ describe('connection lifecycle', () => { } }) - it('converges stream/error frames into reconnect instead of dispatching them', async () => { + it('isolates a connected sink exception from the generation', async () => { const api = new FakeApiClient() - const muxSeen: string[] = [] - let connected = 0 - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { - onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type), - onConnected: () => { connected++ }, - }, FAST) - controller.start() - try { - await vi.waitFor(() => { expect(connected).toBe(1) }) - api.pushMux({ type: 'stream/error', error: { code: 'internal', message: 'impl broke', details: {} } }) - await vi.waitFor(() => { expect(connected).toBe(2) }) // treated as loss → reconnect - expect(muxSeen).toEqual([]) // never forwarded to the business sink - } finally { - controller.stop() - warnSpy.mockRestore() - } - }) - - it('isolates sink exceptions from the pump', async () => { - const api = new FakeApiClient() - const seen: string[] = [] let connected = 0 const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { - onMuxEnvelope: (envelope) => { - seen.push(envelope.payload.type) + const controller = new ConnectionController(api, api.generation, { + onConnected: () => { + connected++ throw new Error('business layer bug') }, - onConnected: () => { connected++ }, }, FAST) controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - api.pushMux(subscribedFrame(1)) - api.pushMux(subscribedFrame(2)) - await vi.waitFor(() => { expect(seen).toHaveLength(2) }) // second frame still pumped - expect(connected).toBe(1) // no reconnect triggered by the sink throw + expect(api.openGenerationCount).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error)) } finally { controller.stop() errorSpy.mockRestore() } }) - it('holds onConnected until both streams establish even after describe succeeds', async () => { + it('holds onConnected until the incremental source is ready after describe succeeds', async () => { const api = new FakeApiClient() - api.holdStreamOpen = true // describe resolves immediately; stream establishment is in the case's hand + api.holdGenerationReady = true let connected = 0 - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) await new Promise(resolve => setTimeout(resolve, 30)) expect(connected).toBe(0) // describe alone must not announce - api.releaseStreamOpens() + api.releaseGenerationReady() await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() } }) - it('rejects a generation whose streams end during readiness and retries', async () => { + it('rejects a generation whose source ends during readiness and retries', async () => { const api = new FakeApiClient() const firstDescribe = deferred>>() let describeCalls = 0 @@ -193,13 +158,13 @@ describe('connection lifecycle', () => { const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) }) + await vi.waitFor(() => { expect(api.openGenerationCount).toBe(1) }) api.endStreams() firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) @@ -212,16 +177,54 @@ describe('connection lifecycle', () => { } }) - it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => { + it.each([ + { label: 'ends normally', fail: () => Promise.resolve() }, + { + label: 'rejects with a non-Error reason', + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error source normalization is the scenario. + fail: () => Promise.reject('fixture offline'), + }, + ])('retries when the generation source $label before reporting ready', async ({ fail }) => { const api = new FakeApiClient() - api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires + let sourceCalls = 0 let connected = 0 - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, { ...FAST, streamOpenTimeoutMs: 20 }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController(api, (signal, ready) => { + sourceCalls++ + if (sourceCalls === 1) return fail() + ready() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + }, { onConnected: () => { connected++ } }, FAST) controller.start() try { - await vi.waitFor(() => { expect(connected).toBe(1) }) // handshake resolved by the guard, not wedged + await vi.waitFor(() => { expect(sourceCalls).toBe(2) }) + await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() + warnSpy.mockRestore() + } + }) + + it('rejects and retries a generation whose source never reports ready', async () => { + const api = new FakeApiClient() + api.suppressGenerationReady = true + let connected = 0 + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController( + api, + api.generation, + { onConnected: () => { connected++ } }, + { ...FAST, generationReadyTimeoutMs: 20 }, + ) + controller.start() + try { + await vi.waitFor(() => { expect(api.callsOf('host.describe').length).toBeGreaterThan(1) }) + expect(connected).toBe(0) + } finally { + controller.stop() + warnSpy.mockRestore() } }) @@ -230,7 +233,7 @@ describe('connection lifecycle', () => { const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) @@ -251,7 +254,7 @@ describe('connection lifecycle', () => { const api = new FakeApiClient() const states: ConnectionState[] = [] let connected = 0 - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: (state) => { states.push(state) @@ -261,7 +264,7 @@ describe('connection lifecycle', () => { controller.start() await vi.waitFor(() => { expect(states).toEqual(['connected']) }) - await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) }) + await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) expect(connected).toBe(0) }) @@ -276,7 +279,7 @@ describe('connection lifecycle', () => { const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) @@ -294,11 +297,10 @@ describe('connection lifecycle', () => { it('runs with no sinks at all (every callback slot optional)', async () => { const api = new FakeApiClient() - const controller = new ConnectionController(api, {}, FAST) + const controller = new ConnectionController(api, api.generation, {}, FAST) controller.start() try { await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) - api.pushMux(subscribedFrame()) // pumped with sink undefined: dropped silently await new Promise(resolve => setTimeout(resolve, 20)) } finally { controller.stop() @@ -308,12 +310,12 @@ describe('connection lifecycle', () => { it('start() is idempotent (one loop, one stream set)', async () => { const api = new FakeApiClient() let connected = 0 - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.openMuxCount).toBe(1) + expect(api.openGenerationCount).toBe(1) expect(api.callsOf('host.describe')).toHaveLength(1) } finally { controller.stop() diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index 7c9dc6accb..f0c39b3657 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -1,10 +1,8 @@ // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo // 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, ModelSelection, MuxFrame, - RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, -} from '../src/client/api.ts' +// deferred-controlled timing). The generation source is a hand pump. +import type { IApiClient, RpcResponse, SkillEntry } from '../src/client/api.ts' +import type { ConnectionGenerationSource } from '../src/client/connection.ts' import { RpcId } from '../src/client/api.ts' export interface Deferred { @@ -31,10 +29,10 @@ export function ok(value: T): RpcResponse { } -type StreamItem = { kind: 'frame'; envelope: RpcRequest } | { kind: 'end' } | { kind: 'fail'; error: unknown } +type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown } -interface StreamConn { - feed(item: StreamItem): void +interface StreamConn { + feed(item: StreamItem): void } export class FakeApiClient implements IApiClient { @@ -42,34 +40,6 @@ export class FakeApiClient implements IApiClient { readonly calls: { method: string; payload: unknown }[] = [] // Programmable slots (defaults answer OK-empty); reassign per case. - onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) - onSearch: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ items: [], hasMore: false })) - onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) - onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) - onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) - onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = - () => Promise.resolve(ok({ - events: [], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, - })) - - onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ - current: { provider: 'deepseek-official', model: 'deepseek-chat' }, - routable: true, - groups: [], - failures: [], - })) - onSelectModel: (payload: ModelSelection & { sessionId: SessionId }) - => Promise> = - payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) - onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onAttachment: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) - onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise Promise> = () => Promise.resolve(ok({ path: '/home/fake/new' })) - private readonly muxConns: StreamConn[] = [] - private readonly hostConns: StreamConn[] = [] - lastSearchSignal: AbortSignal | undefined - - // Parameter annotations below are local structural types on purpose: the CI - // lint lane runs without built artifacts, where IApiClient's wire types - // (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument. - readonly sessions: IApiClient['sessions'] = { - list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)), - search: (payload: unknown, signal?: AbortSignal) => { - this.lastSearchSignal = signal - return this.record('session.search', payload, this.onSearch(payload)) - }, - 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: ModelSelection & { sessionId: SessionId }) => - this.record('session.selectModel', payload, this.onSelectModel(payload)), - rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)), - fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)), - prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), - attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), - updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), - cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), - } + private readonly generationConns: StreamConn[] = [] readonly subagents: IApiClient['subagents'] = { list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({ entries: [], parentAvailable: true, }))), - history: (payload: unknown) => this.record('subagent.history', payload, Promise.resolve(ok({ - events: [], - hasMore: false, - }))), prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({ messageId: 'fake-message' as never, }))), @@ -149,27 +90,6 @@ export class FakeApiClient implements IApiClient { openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), } - readonly workspace: IApiClient['workspace'] = { - list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [], archivedSessionIds: [] }))), - 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' }, - }))), - delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))), - insertBefore: (payload: unknown) => this.record('workspace.insertBefore', payload, Promise.resolve(ok({ - workspaceIds: [(payload as { workspaceId: WorkspaceId }).workspaceId], - }))), - 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' }, - }))), - archiveSession: (payload: unknown) => this.record('workspace.archiveSession', payload, Promise.resolve(ok({ - archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId], - }))), - } - // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program catalogs and skill lists without casts. onSkillList: (payload: unknown) => Promise> @@ -225,51 +145,33 @@ export class FakeApiClient implements IApiClient { discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), } - /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ - suppressStreamOpen = false + /** When true, the source never reports ready. */ + suppressGenerationReady = false - /** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them. - * Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */ - holdStreamOpen = false + /** When true, ready callbacks remain parked until the test releases them. */ + holdGenerationReady = false private heldOpens: (() => void)[] = [] - releaseStreamOpens(): void { + releaseGenerationReady(): void { const held = this.heldOpens this.heldOpens = [] for (const fire of held) fire() } - readonly events: IApiClient['events'] = { - mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => - this.openStream(this.muxConns, signal, onOpen), - host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => - this.openStream(this.hostConns, signal, onOpen), - } - - respond(): Promise<{ accepted: false; reason: 'not-pending' }> { - return Promise.resolve({ accepted: false, reason: 'not-pending' }) - } - - /** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */ - pushMux(frame: MuxFrame, rpcId?: string): void { - for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } }) - } - - pushHost(frame: HostFrame, rpcId?: string): void { - for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } }) - } + readonly generation: ConnectionGenerationSource = (signal, ready) => + this.openGeneration(signal, ready) /** End (clean close) or fail (throw) every open stream — reconnect-path material. */ endStreams(): void { - for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' }) + for (const conn of [...this.generationConns]) conn.feed({ kind: 'end' }) } failStreams(error: unknown): void { - for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error }) + for (const conn of [...this.generationConns]) conn.feed({ kind: 'fail', error }) } - get openMuxCount(): number { - return this.muxConns.length + get openGenerationCount(): number { + return this.generationConns.length } callsOf(method: string): unknown[] { @@ -281,25 +183,24 @@ export class FakeApiClient implements IApiClient { return response } - private async *openStream(registry: StreamConn[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator> { - const inbox: StreamItem[] = [] + private async openGeneration(signal: AbortSignal, onOpen: () => void): Promise { + const inbox: StreamItem[] = [] let wake: (() => void) | null = null - const conn: StreamConn = { + const conn: StreamConn = { feed: (item) => { inbox.push(item) wake?.() }, } - registry.push(conn) - if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen) - else if (!this.suppressStreamOpen) onOpen?.() + this.generationConns.push(conn) + if (this.holdGenerationReady) this.heldOpens.push(onOpen) + else if (!this.suppressGenerationReady) onOpen() try { while (!signal.aborted) { while (inbox.length > 0) { - const item = inbox.shift() as StreamItem + const item = inbox.shift() as StreamItem if (item.kind === 'end') return if (item.kind === 'fail') throw item.error - yield item.envelope } await new Promise((resolve) => { wake = resolve @@ -308,7 +209,7 @@ export class FakeApiClient implements IApiClient { wake = null } } finally { - registry.splice(registry.indexOf(conn), 1) + this.generationConns.splice(this.generationConns.indexOf(conn), 1) } } } diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index 62118062b5..163fb414a6 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -45,15 +45,18 @@ describe('createFixtureApi commands/skills', () => { expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) - it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => { - const { api, rpc } = createFixtureFaces() + it('executes a known command line: pure admission plus a followed lifecycle pair', async () => { + const { rpc } = createFixtureFaces() const frames: unknown[] = [] const abort = new AbortController() - const stream = api.events.mux(req({}), abort.signal) + const stream = rpc.open?.('/api', 'session/follow', { + args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } }, + }, abort.signal) + if (stream === undefined) throw new Error('fixture session follow stream is unavailable') const pump = (async () => { for await (const frame of stream) { - frames.push(frame.payload) - if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort() + frames.push(frame) + if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort() } })() const execution = await callRemote<{ commandId: string } | undefined>( @@ -61,7 +64,7 @@ describe('createFixtureApi commands/skills', () => { expect(execution?.commandId).toBeTruthy() await pump const events = frames - .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event') + .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'event') .map(f => f.event) expect(events).toMatchObject([ { type: 'command/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } }, @@ -83,14 +86,17 @@ describe('createFixtureApi commands/skills', () => { }) it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => { - const { api, rpc } = createFixtureFaces() + const { rpc } = createFixtureFaces() const frames: unknown[] = [] const abort = new AbortController() - const stream = api.events.mux(req({}), abort.signal) + const stream = rpc.open?.('/api', 'session/follow', { + args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } }, + }, abort.signal) + if (stream === undefined) throw new Error('fixture session follow stream is unavailable') const pump = (async () => { for await (const frame of stream) { - frames.push(frame.payload) - if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort() + frames.push(frame) + if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort() } })() const png = { mediaType: 'image/png', data: 'AA==' } @@ -100,7 +106,7 @@ describe('createFixtureApi commands/skills', () => { expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept image attachments' }) await pump const events = frames - .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event') + .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'event') .map(f => f.event) expect(events).toMatchObject([ { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } }, diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 0842eb8ed3..58c347fec3 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -1,13 +1,423 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionId, WorkspaceId } from '../src/client/api.ts' +import type { + ModelProviderGroup, + ModelSelection, + RpcMessage, + RpcRequest, + RpcResponse, + RpcResult, + SessionEvent, + SessionId, +} 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' +import { + FixtureApiClient, + createFixtureFaces, + type FixtureOptions, +} from '../src/client/fixture.ts' +import type { + ClientConnectionRpc, +} from '../src/rpc.ts' const sid = (id: string): SessionId => id as SessionId +type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } const req =

(payload: P): RpcRequest

=> ({ rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`), payload }) let reqCount = 0 +interface FixtureSessionSummary { + sessionId: SessionId + updatedAt: number + running: boolean + blank: boolean + parentSessionId?: SessionId + origin?: 'subagent' + cwd?: string + agentPreset?: string +} + +interface FixtureHistoryEntry { + readonly event: SessionEvent + readonly view?: unknown +} + +interface FixturePage { + readonly events: readonly FixtureHistoryEntry[] + readonly hasMore: boolean + readonly projections?: { + readonly asOfSeq: number + readonly values: Readonly> + } +} + +type FixtureFollowFrame = + | { readonly type: 'opened'; readonly cursor: number } + | ({ readonly type: 'event' } & FixtureHistoryEntry) + +type FixtureControlFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly queues: Readonly> + readonly jobs: Readonly> + readonly approvals: readonly unknown[] + readonly questions: readonly unknown[] + readonly projections: Readonly> + }>> + } + } + | { + readonly type: 'projection' + readonly sessionId: SessionId + readonly key: string + readonly value: unknown + readonly seq: number + } + +interface FixtureSessionRequests { + list: { readonly cursor?: string } + search: { readonly query: string } + create: { + readonly workspaceId?: WorkspaceId + readonly cwd?: string + readonly sessionId?: SessionId + readonly agentPreset?: string + } + history: { + readonly sessionId: SessionId + readonly beforeSeq?: number + readonly maxMessages?: number + } + models: { readonly sessionId: SessionId } + selectModel: { + readonly sessionId: SessionId + readonly provider: string + readonly model: string + readonly reasoningEffort?: string + } + prompt: { + readonly sessionId: SessionId + readonly mode: 'queue' | 'steer' + readonly content: readonly ({ readonly type: 'text'; readonly text: string } | { + readonly type: 'image' + readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + readonly data: string + readonly name?: string + })[] + } + cancel: { readonly sessionId: SessionId } + rename: { readonly sessionId: SessionId; readonly title: string } +} + +interface FixtureSessionValues { + list: { readonly items: FixtureSessionSummary[] } + search: { readonly items: readonly { readonly sessionId: SessionId; readonly snippet: string }[]; readonly hasMore: boolean } + create: { readonly sessionId: SessionId } + history: FixturePage + models: { + readonly current: ModelSelection + readonly routable: boolean + readonly groups: readonly ModelProviderGroup[] + readonly failures: readonly unknown[] + } + selectModel: { readonly selected: ModelSelection } + prompt: { readonly accepted: true } + cancel: Record + rename: { readonly title: string; readonly seq: number } +} + +type FixtureSessionApi = { + [K in keyof FixtureSessionRequests]: ( + request: RpcRequest, + signal?: AbortSignal, + ) => Promise> +} + +type FixtureSessionClient = { + [K in keyof FixtureSessionRequests]: ( + request: FixtureSessionRequests[K], + signal?: AbortSignal, + ) => Promise> +} + +interface FixtureSessionRemote { + follow(sessionId: SessionId, signal: AbortSignal, afterSeq?: number): AsyncIterable + control(signal: AbortSignal): AsyncIterable +} + +interface FixtureWorkspaceView { + readonly workspaceId: WorkspaceId + readonly path: string + readonly title: string + readonly sessionIds: readonly SessionId[] + readonly createdAt: string + readonly updatedAt: string +} + +interface FixtureWorkspaceRequests { + create: { readonly path: string } + rename: { readonly workspaceId: WorkspaceId; readonly title: string } + delete: { readonly workspaceId: WorkspaceId } + insertBefore: { readonly workspaceId: WorkspaceId; readonly beforeWorkspaceId?: WorkspaceId } + insertSessionBefore: { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId + } + archiveSession: { readonly sessionId: SessionId } +} + +interface FixtureWorkspaceValues { + create: { readonly workspace: FixtureWorkspaceView; readonly created: boolean } + rename: { readonly workspace: FixtureWorkspaceView } + delete: { readonly deleted: true } + insertBefore: { readonly workspaceIds: readonly WorkspaceId[] } + insertSessionBefore: { readonly workspace: FixtureWorkspaceView } + archiveSession: { readonly archivedSessionIds: readonly SessionId[] } +} + +type FixtureWorkspaceApi = { + [K in keyof FixtureWorkspaceRequests]: ( + request: RpcRequest, + signal?: AbortSignal, + ) => Promise> +} + +type FixtureWorkspaceClient = { + [K in keyof FixtureWorkspaceRequests]: ( + request: FixtureWorkspaceRequests[K], + signal?: AbortSignal, + ) => Promise> +} + +type FixtureWorkspaceFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly items: readonly FixtureWorkspaceView[] + readonly archivedSessionIds: readonly SessionId[] + } + } + | { readonly type: 'upsert'; readonly workspace: FixtureWorkspaceView } + | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } + | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } + | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } + +interface FixtureWorkspaceRemote { + follow(signal: AbortSignal): AsyncIterable +} + +interface FixtureRemoteEventNotificationFrame { + readonly type: 'emit' + readonly event: string + readonly args: readonly unknown[] +} + +interface FixtureRemoteEventRequestFrame { + readonly type: 'waterfall' + readonly event: string + readonly eventId: string + readonly agentId: SessionId + readonly request: Readonly> +} + +interface FixtureRemoteEventCancellationFrame { + readonly type: 'cancel' + readonly eventId: string +} + +type FixtureRemoteEventFrame = + | FixtureRemoteEventNotificationFrame + | FixtureRemoteEventRequestFrame + | FixtureRemoteEventCancellationFrame + +interface FixtureRemoteEventResult { + readonly clientId: string + readonly eventId: string + readonly outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { + readonly kind: 'rejected' + readonly error: { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown + } + } +} + +interface FixtureRemoteEventStream extends AsyncIterable { + readonly clientId: Promise +} + +type FixtureTestApi = ReturnType['api'] & { + readonly sessions: FixtureSessionApi + readonly sessionRemote: FixtureSessionRemote + readonly workspace: FixtureWorkspaceApi + readonly workspaceRemote: FixtureWorkspaceRemote + readonly remoteEvents: (signal: AbortSignal) => FixtureRemoteEventStream + readonly answerRemoteEvent: (result: FixtureRemoteEventResult) => Promise +} + +/** Keep existing fixture assertions compact while driving only the new Session Remote endpoints. */ +function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { + const { api, rpc } = createFixtureFaces(options) + return Object.assign(api, { + sessions: createSessionApi(rpc), + sessionRemote: createSessionRemote(rpc), + workspace: createWorkspaceApi(rpc), + workspaceRemote: createWorkspaceRemote(rpc), + remoteEvents: (signal: AbortSignal) => openFixtureRemoteEvents(rpc, signal), + answerRemoteEvent: (result: FixtureRemoteEventResult) => + rpc.call('/api', '$events/result', { args: result }), + }) +} + +function openFixtureRemoteEvents( + rpc: ClientConnectionRpc, + signal: AbortSignal, +): FixtureRemoteEventStream { + const ready = Promise.withResolvers() + const source = (async function* (): AsyncGenerator { + const stream = rpc.open?.('/api', '$events', { args: {} }, signal) + if (stream === undefined) throw new Error('fixture forwarded-event stream is unavailable') + let opened = false + for await (const value of stream) { + if (!opened) { + expect(value).toMatchObject({ type: 'ready' }) + const clientId: unknown = Reflect.get(value as object, 'clientId') + if (typeof clientId !== 'string') throw new Error('fixture forwarded-event stream omitted its Client id') + ready.resolve(clientId) + opened = true + continue + } + yield value as FixtureRemoteEventFrame + } + })() + return Object.assign(source, { clientId: ready.promise }) +} + +function createSessionApi(rpc: ClientConnectionRpc): FixtureSessionApi { + const call = async ( + endpoint: K, + request: RpcRequest, + signal?: AbortSignal, + ): Promise> => { + const page = endpoint === 'history' + ? request.payload as FixtureSessionRequests['history'] + : undefined + const args = endpoint === 'list' + ? { _request: request.payload } + : endpoint === 'history' + ? { + request: { + address: { kind: 'session', sessionId: page?.sessionId }, + ...page?.beforeSeq === undefined ? {} : { beforeSeq: page.beforeSeq }, + ...page?.maxMessages === undefined ? {} : { maxMessages: page.maxMessages }, + }, + } + : { request: request.payload } + const remoteEndpoint = endpoint === 'history' ? 'page' : endpoint + const result = await rpc.call('/api', `session/${remoteEndpoint}`, { args }, signal) + return { + rpcId: request.rpcId, + result: result as unknown as RpcResult, + } + } + return { + list: (request, signal) => call('list', request, signal), + search: (request, signal) => call('search', request, signal), + create: (request, signal) => call('create', request, signal), + history: (request, signal) => call('history', request, signal), + models: (request, signal) => call('models', request, signal), + selectModel: (request, signal) => call('selectModel', request, signal), + prompt: (request, signal) => call('prompt', request, signal), + cancel: (request, signal) => call('cancel', request, signal), + rename: (request, signal) => call('rename', request, signal), + } +} + +function createSessionClient(rpc: ClientConnectionRpc): FixtureSessionClient { + const api = createSessionApi(rpc) + return { + list: (request, signal) => api.list(req(request), signal), + search: (request, signal) => api.search(req(request), signal), + create: (request, signal) => api.create(req(request), signal), + history: (request, signal) => api.history(req(request), signal), + models: (request, signal) => api.models(req(request), signal), + selectModel: (request, signal) => api.selectModel(req(request), signal), + prompt: (request, signal) => api.prompt(req(request), signal), + cancel: (request, signal) => api.cancel(req(request), signal), + rename: (request, signal) => api.rename(req(request), signal), + } +} + +function createSessionRemote(rpc: ClientConnectionRpc): FixtureSessionRemote { + const open = (endpoint: string, args: object, signal: AbortSignal): AsyncIterable => { + const stream = rpc.open?.('/api', endpoint, { args }, signal) + if (stream === undefined) throw new Error(`fixture ${endpoint} stream is unavailable`) + return stream as AsyncIterable + } + return { + follow: (sessionId, signal, afterSeq) => open('session/follow', { + request: { + address: { kind: 'session', sessionId }, + ...afterSeq === undefined ? {} : { afterSeq }, + }, + }, signal), + control: signal => open('session/control', {}, signal), + } +} + +function createWorkspaceApi(rpc: ClientConnectionRpc): FixtureWorkspaceApi { + const call = async ( + endpoint: K, + request: RpcRequest, + signal?: AbortSignal, + ): Promise> => { + const result = await rpc.call('/api', `workspace/${endpoint}`, { + args: { request: request.payload }, + }, signal) + return { + rpcId: request.rpcId, + result: result as unknown as RpcResult, + } + } + return { + create: (request, signal) => call('create', request, signal), + rename: (request, signal) => call('rename', request, signal), + delete: (request, signal) => call('delete', request, signal), + insertBefore: (request, signal) => call('insertBefore', request, signal), + insertSessionBefore: (request, signal) => call('insertSessionBefore', request, signal), + archiveSession: (request, signal) => call('archiveSession', request, signal), + } +} + +function createWorkspaceClient(rpc: ClientConnectionRpc): FixtureWorkspaceClient { + const api = createWorkspaceApi(rpc) + return { + create: (request, signal) => api.create(req(request), signal), + rename: (request, signal) => api.rename(req(request), signal), + delete: (request, signal) => api.delete(req(request), signal), + insertBefore: (request, signal) => api.insertBefore(req(request), signal), + insertSessionBefore: (request, signal) => api.insertSessionBefore(req(request), signal), + archiveSession: (request, signal) => api.archiveSession(req(request), signal), + } +} + +function createWorkspaceRemote(rpc: ClientConnectionRpc): FixtureWorkspaceRemote { + return { + follow(signal) { + const stream = rpc.open?.('/api', 'workspace/follow', { args: {} }, signal) + if (stream === undefined) throw new Error('fixture workspace/follow stream is unavailable') + return stream as AsyncIterable + }, + } +} + interface TimingHooks { setHistoryDelay(ms: number): void failNextHistory(): void @@ -32,11 +442,11 @@ interface TimingHooks { } const timing = (): TimingHooks => (globalThis as Record).__fxTiming as TimingHooks -/** Collect stream frames until the predicate or a soft cap; abort ends the stream. */ -async function collect(stream: AsyncIterable>, abort: AbortController, done: (frames: F[]) => boolean): Promise { +/** Collect value-stream frames until the predicate or a soft cap; abort ends the stream. */ +async function collectValues(stream: AsyncIterable, abort: AbortController, done: (frames: F[]) => boolean): Promise { const frames: F[] = [] - for await (const envelope of stream) { - frames.push(envelope.payload) + for await (const frame of stream) { + frames.push(frame) if (done(frames) || frames.length > 500) { abort.abort() break @@ -45,6 +455,70 @@ async function collect(stream: AsyncIterable>, abort: AbortCont return frames } +async function readControlBaseline(remote: FixtureSessionRemote): Promise> { + const abort = new AbortController() + for await (const frame of remote.control(abort.signal)) { + if (frame.type !== 'baseline') continue + abort.abort() + return frame + } + throw new Error('fixture control baseline missing') +} + +function isRemoteEventRequest(frame: FixtureRemoteEventFrame): frame is FixtureRemoteEventRequestFrame { + return frame.type === 'waterfall' +} + +function isRemoteEventCancellation(frame: FixtureRemoteEventFrame): frame is FixtureRemoteEventCancellationFrame { + return frame.type === 'cancel' +} + +async function readResidentRemoteEvents( + api: FixtureTestApi, + count: number, +): Promise { + const abort = new AbortController() + const frames = await collectValues( + api.remoteEvents(abort.signal), + abort, + seen => seen.filter(isRemoteEventRequest).length >= count, + ) + return frames.filter(isRemoteEventRequest) +} + +async function nextRemoteEvent( + iterator: AsyncIterator, + predicate: (frame: FixtureRemoteEventFrame) => boolean, +): Promise { + for (;;) { + const item = await iterator.next() + if (item.done) throw new Error('fixture Remote Event stream ended before the expected frame') + if (predicate(item.value)) return item.value + } +} + +async function readOpeningCursor(remote: FixtureSessionRemote, sessionId: SessionId): Promise { + const abort = new AbortController() + for await (const frame of remote.follow(sessionId, abort.signal)) { + if (frame.type !== 'opened') continue + abort.abort() + return frame.cursor + } + throw new Error('fixture follow opening cursor missing') +} + +async function readWorkspaceBaseline( + remote: FixtureWorkspaceRemote, +): Promise['value']> { + const abort = new AbortController() + for await (const frame of remote.follow(abort.signal)) { + if (frame.type !== 'baseline') continue + abort.abort() + return frame.value + } + throw new Error('fixture Workspace baseline missing') +} + describe('createFixtureApi', () => { it('serves the session list sorted by updatedAt desc and echoes rpcIds on every unary', async () => { const api = createFixtureApi() @@ -254,14 +728,16 @@ describe('createFixtureApi', () => { expect(snapshot.data.todos.filter(t => t.status === 'in_progress')).toHaveLength(2) }) - it('create adds a session and pushes host/session-added to open host streams', async () => { + it('create adds a session and announces it through the Host Remote event stream', async () => { const api = createFixtureApi() const abort = new AbortController() - const seen: HostFrame[] = [] + const seen: FixtureRemoteEventNotificationFrame[] = [] const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) { - seen.push(envelope.payload) - if (seen.length >= 1) abort.abort() + for await (const frame of api.remoteEvents(abort.signal)) { + if (frame.type !== 'emit' || frame.event !== 'api-session/added') continue + seen.push(frame) + abort.abort() + break } })() await new Promise(resolve => setTimeout(resolve, 10)) // let the stream register @@ -272,9 +748,9 @@ describe('createFixtureApi', () => { const createdId = created.result.value.sessionId expect(seen).toHaveLength(1) const added = seen[0] - if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') - expect(added).toEqual({ - type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture', + expect(added).toMatchObject({ + event: 'api-session/added', + args: [{ sessionId: createdId, blank: true, cwd: '/tmp/fixture' }], }) const list = await api.sessions.list(req({})) if (!list.result.ok) throw new Error('list failed') @@ -286,16 +762,16 @@ describe('createFixtureApi', () => { const created = await api.sessions.create(req({})) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId - const abort = new AbortController() - const frames: MuxFrame[] = [] - const consuming = (async () => { - for await (const envelope of api.events.mux(req({}), abort.signal)) { - frames.push(envelope.payload) - const last = envelope.payload - if (last.type === 'session/event' && last.event.type === 'turn/end') { - abort.abort() - } - } + const followAbort = new AbortController() + const controlAbort = new AbortController() + const controlFrames: FixtureControlFrame[] = [] + const followPromise = collectValues( + api.sessionRemote.follow(id, followAbort.signal), + followAbort, + frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end'), + ) + const controlPromise = (async () => { + for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlFrames.push(frame) })() await new Promise(resolve => setTimeout(resolve, 10)) // Unknown session → session-not-found with the id echoed in details. @@ -306,8 +782,8 @@ describe('createFixtureApi', () => { expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks await api.sessions.cancel(req({ sessionId: id })) - await consuming - const types = frames.filter((f): f is Extract => f.type === 'session/event').map(f => f.event.type) + const frames = await followPromise + const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) expect(types).toContain('turn/start') expect(types).toContain('user/message') expect(types).toContain('assistant/chunk') @@ -316,20 +792,25 @@ describe('createFixtureApi', () => { // Capacity is durable log state, not a transient frame: the prompt path // records request/context and the projection carries it to the client. expect(types).toContain('request/context') - expect(frames.some(frame => - frame.type === 'session/projection' + await vi.waitFor(() => { + expect(controlFrames.some(frame => + frame.type === 'projection' + && frame.key === 'contextBreakdown' + && (frame.value as { messageTokens?: number }).messageTokens! > 0)).toBe(true) + }) + expect(controlFrames.some(frame => + frame.type === 'projection' && frame.key === 'tokenUsage' && (frame.value as { outputTokens?: number }).outputTokens === 8)).toBe(true) - expect(frames.some(frame => - frame.type === 'session/projection' + expect(controlFrames.some(frame => + frame.type === 'projection' && frame.key === 'contextPressure' && (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true) - expect(frames.some(frame => - frame.type === 'session/projection' - && frame.key === 'contextBreakdown' - && (frame.value as { messageTokens?: number }).messageTokens! > 0)).toBe(true) - const finalize = frames.find((f): f is Extract => f.type === 'session/event' && f.event.type === 'assistant/message') + const finalize = frames.find(frame => frame.type === 'event' && frame.event.type === 'assistant/message') + if (finalize?.type !== 'event') throw new Error('assistant final event missing') expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)') + controlAbort.abort() + await controlPromise // Idle cancel: no replay in flight, must not explode; running flips false. const idleCancel = await api.sessions.cancel(req({ sessionId: id })) expect(idleCancel.result).toMatchObject({ ok: true }) @@ -341,99 +822,93 @@ describe('createFixtureApi', () => { if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId const abort = new AbortController() - const framesPromise = collect(api.events.mux(req({}), abort.signal), abort, - frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end')) + const framesPromise = collectValues(api.sessionRemote.follow(id, abort.signal), abort, + frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end')) await new Promise(resolve => setTimeout(resolve, 10)) await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '短' }] })) await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] })) const frames = await framesPromise - const types = frames.filter((f): f is Extract => f.type === 'session/event').map(f => f.event.type) + const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) expect(JSON.stringify(frames)).toContain('插话') expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn }) - it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => { + it('control replays projections while resident Remote Events retain ids across reconnects', async () => { const api = createFixtureApi() - const openOnce = async (): Promise[]> => { - const abort = new AbortController() - const envelopes: RpcRequest[] = [] - for await (const envelope of api.events.mux(req({}), abort.signal)) { - envelopes.push(envelope) - if (envelopes.length >= 13) abort.abort() - } - return envelopes - } - const first = await openOnce() - const second = await openOnce() - expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) - expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - // Projection baseline frames follow subscribed (domain units + token usage). - expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' }) - expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' }) - expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' }) - expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } }) - expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null }) - expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' }) - expect(first[7]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'contextPressure' }) - expect(first[8]?.payload).toMatchObject({ - type: 'session/projection', sessionId: 'fx-alpha', key: 'contextBreakdown', - value: { systemTokens: 0, toolsTokens: 0 }, + const first = await readControlBaseline(api.sessionRemote) + const second = await readControlBaseline(api.sessionRemote) + expect(first.value.approvals).toEqual([]) + expect(first.value.questions).toEqual([]) + const alpha = first.value.projections['fx-alpha'] + expect(alpha?.asOfSeq).toBeGreaterThan(0) + expect(alpha?.values).toMatchObject({ + title: 'Fixture 历史会话', + plan: { active: false, pending: false }, + goal: null, + imageLimits: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 }, }) - expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0) - expect(first[9]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'sessionStats' }) - expect((first[9]?.payload as { value: { turns: number; steps: number } }).value.steps).toBeGreaterThan(0) - expect(first[10]?.payload).toMatchObject({ - type: 'session/projection', sessionId: 'fx-alpha', key: 'imageLimits', - value: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 }, + expect((alpha?.values['contextBreakdown'] as { messageTokens: number }).messageTokens).toBeGreaterThan(0) + expect((alpha?.values['sessionStats'] as { steps: number }).steps).toBeGreaterThan(0) + expect(second.value.projections['fx-alpha']).toEqual(alpha) + + const firstEvents = await readResidentRemoteEvents(api, 2) + const secondEvents = await readResidentRemoteEvents(api, 2) + const firstApproval = firstEvents.find(frame => frame.event === 'approval/request') + const firstQuestion = firstEvents.find(frame => frame.event === 'user-questions/request') + const secondApproval = secondEvents.find(frame => frame.event === 'approval/request') + const secondQuestion = secondEvents.find(frame => frame.event === 'user-questions/request') + expect(firstApproval).toMatchObject({ + type: 'waterfall', + request: { toolName: 'dangerous_tool' }, + agentId: 'fx-alpha', }) - expect(first[11]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[11]?.rpcId).toBe(first[11]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[12]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[12]?.rpcId).toBe(first[12]?.rpcId) + expect(firstQuestion).toMatchObject({ + type: 'waterfall', + agentId: 'fx-alpha', + }) + expect(Array.isArray(firstQuestion?.request.questions)).toBe(true) + expect(secondApproval?.eventId).toBe(firstApproval?.eventId) + expect(secondQuestion?.eventId).toBe(firstQuestion?.eventId) + expect(await readOpeningCursor(api.sessionRemote, sid('fx-alpha'))).toBeGreaterThan(0) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { const api = createFixtureApi() - const abort = new AbortController() - const framesPromise = collect(api.events.mux(req({}), abort.signal), abort, - frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end')) - await new Promise(resolve => setTimeout(resolve, 10)) const created = await api.sessions.create(req({})) if (!created.result.ok) throw new Error('create failed') + const abort = new AbortController() + const framesPromise = collectValues( + api.sessionRemote.follow(created.result.value.sessionId, abort.signal), + abort, + frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end'), + ) + await new Promise(resolve => setTimeout(resolve, 10)) // steer while idle + a non-text content block (covers the '' arm of the text join). await api.sessions.prompt(req({ sessionId: created.result.value.sessionId, mode: 'steer' as const, content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never], })) const frames = await framesPromise - const types = frames.filter((f): f is Extract => f.type === 'session/event').map(f => f.event.type) + const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not an in-turn insert }) - it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => { + it('gamma interval flip emits a Remote status event and its empty follow source opens at -1', async () => { vi.useFakeTimers() try { const api = createFixtureApi() const abort = new AbortController() - const hostSeen: HostFrame[] = [] + const hostSeen: FixtureRemoteEventFrame[] = [] const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) hostSeen.push(envelope.payload) + for await (const frame of api.remoteEvents(abort.signal)) hostSeen.push(frame) })() await vi.advanceTimersByTimeAsync(5001) // interval fires: fx-gamma flips running=true (no log exists) - expect(hostSeen).toContainEqual({ type: 'host/session-status', sessionId: sid('fx-gamma'), running: true }) - // A mux stream opened now sees gamma in the baseline with lastSeq = -1 (empty log arm). - const mabort = new AbortController() - const baseline: MuxFrame[] = [] - const muxConsuming = (async () => { - for await (const envelope of api.events.mux(req({}), mabort.signal)) { - baseline.push(envelope.payload) - if (baseline.length >= 3) mabort.abort() - } - })() - await vi.advanceTimersByTimeAsync(10) - mabort.abort() - await muxConsuming - expect(baseline).toContainEqual({ type: 'session/subscribed', sessionId: sid('fx-gamma'), lastSeq: -1 }) + expect(hostSeen).toContainEqual({ + type: 'emit', + event: 'api-session/status', + args: [sid('fx-gamma'), true], + }) + expect(await readOpeningCursor(api.sessionRemote, sid('fx-gamma'))).toBe(-1) abort.abort() await vi.advanceTimersByTimeAsync(10) await consuming @@ -442,76 +917,97 @@ describe('createFixtureApi', () => { } }) - it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => { + it('answers a resident question through its Remote Event id and stops replaying it', async () => { const api = createFixtureApi() - expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' }) const abort = new AbortController() - let question: RpcRequest | undefined - for await (const envelope of api.events.mux(req({}), abort.signal)) { - if (envelope.payload.type !== 'question/requested') continue - question = envelope - abort.abort() - } - if (question === undefined) throw new Error('fixture question missing') - const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } } - expect(await api.respond(response)).toEqual({ accepted: true }) - expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' }) + const stream = api.remoteEvents(abort.signal) + const iterator = stream[Symbol.asyncIterator]() + const question = await nextRemoteEvent( + iterator, + frame => isRemoteEventRequest(frame) && frame.event === 'user-questions/request', + ) + if (!isRemoteEventRequest(question)) throw new Error('fixture question Remote Event missing') + const clientId = await stream.clientId + await expect(api.answerRemoteEvent({ + clientId, + eventId: 'unrelated', + outcome: { kind: 'result', value: {} }, + })).resolves.toEqual({ ok: true, value: undefined }) + await expect(api.answerRemoteEvent({ + clientId, + eventId: question.eventId, + outcome: { kind: 'result', value: { answers: {} } }, + })).resolves.toEqual({ ok: true, value: undefined }) + const cancelled = await nextRemoteEvent( + iterator, + frame => isRemoteEventCancellation(frame) && frame.eventId === question.eventId, + ) + expect(cancelled).toEqual({ type: 'cancel', eventId: question.eventId }) + abort.abort() + await iterator.return?.() - const replayAbort = new AbortController() - const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2) - expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true) + await expect(api.answerRemoteEvent({ + clientId, + eventId: question.eventId, + outcome: { kind: 'result', value: { answers: {} } }, + })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } }) + const remaining = await readResidentRemoteEvents(api, 1) + expect(remaining.map(frame => frame.event)).toEqual(['approval/request']) const cancelledApi = createFixtureApi() const cancelAbort = new AbortController() - let cancelQuestion: RpcRequest | undefined - for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) { - if (envelope.payload.type !== 'question/requested') continue - cancelQuestion = envelope - cancelAbort.abort() - } - if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing') - expect(await cancelledApi.respond({ - type: 'client-response', rpcId: cancelQuestion.rpcId, - result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } }, - })).toEqual({ accepted: true }) + const cancelStream = cancelledApi.remoteEvents(cancelAbort.signal) + const cancelIterator = cancelStream[Symbol.asyncIterator]() + const cancelQuestion = await nextRemoteEvent( + cancelIterator, + frame => isRemoteEventRequest(frame) && frame.event === 'user-questions/request', + ) + if (!isRemoteEventRequest(cancelQuestion)) throw new Error('fixture cancellation question missing') + await expect(cancelledApi.answerRemoteEvent({ + clientId: await cancelStream.clientId, + eventId: cancelQuestion.eventId, + outcome: { + kind: 'rejected', + error: { name: 'UserQuestionError', message: 'skip', code: 'ASK_CANCELLED' }, + }, + })).resolves.toEqual({ ok: true, value: undefined }) + cancelAbort.abort() + await cancelIterator.return?.() + const afterCancellation = await readResidentRemoteEvents(cancelledApi, 1) + expect(afterCancellation.map(frame => frame.event)).toEqual(['approval/request']) }) - it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => { + it('answers a resident approval and broadcasts cancellation to its active delivery', async () => { const api = createFixtureApi() - // Discover the resident approval's stable rpcId from the mux baseline. const abort = new AbortController() - const seen: { rpcId: string; frame: MuxFrame }[] = [] - const consuming = (async () => { - for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload }) - })() - await vi.waitFor(() => { - expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true) - }) - const requested = seen.find(s => s.frame.type === 'approval/requested') - if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable') - const approvalId = requested.frame.approvalId + const stream = api.remoteEvents(abort.signal) + const iterator = stream[Symbol.asyncIterator]() + const approval = await nextRemoteEvent( + iterator, + frame => isRemoteEventRequest(frame) && frame.event === 'approval/request', + ) + if (!isRemoteEventRequest(approval)) throw new Error('fixture approval Remote Event missing') - // Routed but malformed answers. - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } })) - .toEqual({ accepted: false, reason: 'bad-response' }) - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } })) - .toEqual({ accepted: false, reason: 'bad-response' }) - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } })) - .toEqual({ accepted: false, reason: 'bad-response' }) - // The real answer settles the question and broadcasts resolved. - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } })) - .toEqual({ accepted: true }) - await vi.waitFor(() => { - expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true) - }) - // Settled: a duplicate answer is late, and a fresh mux open replays nothing. - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } })) - .toEqual({ accepted: false, reason: 'not-pending' }) + await expect(api.answerRemoteEvent({ + clientId: await stream.clientId, + eventId: approval.eventId, + outcome: { kind: 'result', value: 'allowed-once' }, + })).resolves.toEqual({ ok: true, value: undefined }) + const cancelled = await nextRemoteEvent( + iterator, + frame => isRemoteEventCancellation(frame) && frame.eventId === approval.eventId, + ) + expect(cancelled).toEqual({ type: 'cancel', eventId: approval.eventId }) abort.abort() - await consuming - const abort2 = new AbortController() - const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2) - expect(replayed.some(f => f.type === 'approval/requested')).toBe(false) + await iterator.return?.() + + await expect(api.answerRemoteEvent({ + clientId: await stream.clientId, + eventId: approval.eventId, + outcome: { kind: 'next' }, + })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } }) + const remaining = await readResidentRemoteEvents(api, 1) + expect(remaining.map(frame => frame.event)).toEqual(['user-questions/request']) }) it('describe answers the fixture identity', async () => { @@ -540,11 +1036,10 @@ describe('createFixtureApi', () => { expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) }) - it('workspace.list serves the resident account and create reuses on path collision', async () => { + it('workspace/follow serves the resident baseline 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([ + const baseline = await readWorkspaceBaseline(api.workspaceRemote) + expect(baseline.items).toEqual([ expect.objectContaining({ workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], @@ -560,16 +1055,15 @@ describe('createFixtureApi', () => { expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } }) }) - it('workspace.create on a fresh path mints a new entity and pushes host/workspace-changed', async () => { + it('workspace.create on a fresh path mints a new entity and pushes an upsert', 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() - } - })() + const consuming = collectValues( + api.workspaceRemote.follow(abort.signal), + abort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.path === '/tmp/fixture-workspaces/nova'), + ) await new Promise(resolve => setTimeout(resolve, 10)) const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!created.result.ok) throw new Error('create failed') @@ -577,8 +1071,8 @@ describe('createFixtureApi', () => { 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 }]) + const frames = await consuming + expect(frames.at(-1)).toEqual({ type: 'upsert', workspace: created.result.value.workspace }) // A basename-less path serves as its own title. const rootPath = await api.workspace.create(req({ path: '/' })) if (!rootPath.result.ok) throw new Error('rootPath failed') @@ -588,13 +1082,11 @@ describe('createFixtureApi', () => { 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() - } - })() + const consuming = collectValues( + api.workspaceRemote.follow(abort.signal), + abort, + frames => frames.filter(frame => frame.type === 'upsert').length >= 2, + ) 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' })) @@ -611,22 +1103,28 @@ describe('createFixtureApi', () => { 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 + const frames = 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']) + const upserts = frames.filter(frame => frame.type === 'upsert') + expect(upserts).toHaveLength(2) + expect(upserts[1]).toMatchObject({ workspace: { workspaceId: wsid, title: 'renamed' } }) }) it('session.rename covers not-found, blank title, and the accepted append + title frame', async () => { const api = createFixtureApi() - const abort = new AbortController() - const framesPromise = (async () => { - const frames: MuxFrame[] = [] - for await (const envelope of api.events.mux(req({}), abort.signal)) { - frames.push(envelope.payload) - if (frames.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === '重命名')) abort.abort() - } - return frames - })() + const followAbort = new AbortController() + const controlAbort = new AbortController() + const followPromise = collectValues( + api.sessionRemote.follow(sid('fx-alpha'), followAbort.signal), + followAbort, + frames => frames.some(frame => frame.type === 'event' + && (frame.event as { type: string }).type === 'session/title'), + ) + const controlPromise = collectValues( + api.sessionRemote.control(controlAbort.signal), + controlAbort, + frames => frames.some(frame => frame.type === 'projection' && frame.key === 'title' && frame.value === '重命名'), + ) await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' })) @@ -649,10 +1147,16 @@ describe('createFixtureApi', () => { type: 'session/title', data: { title: '重命名', messageSeqs: [], source: { kind: 'user' } }, }) - // Beyond the subscribe-time baseline replay, the append emitted exactly - // one title projection frame carrying the new value at the response seq. - const frames = await framesPromise - const titleFrames = frames.filter(f => f.type === 'session/projection' && f.key === 'title' && f.sessionId === sid('fx-alpha') && f.value === '重命名') + const followed = await followPromise + expect(followed.some(frame => frame.type === 'event' + && frame.event.seq === acceptedSeq + && (frame.event as { readonly type: string }).type === 'session/title')).toBe(true) + const frames = await controlPromise + const titleFrames = frames.filter(frame => + frame.type === 'projection' + && frame.key === 'title' + && frame.sessionId === sid('fx-alpha') + && frame.value === '重命名') expect(titleFrames).toHaveLength(1) expect(titleFrames[0]).toMatchObject({ seq: acceptedSeq }) }) @@ -683,23 +1187,20 @@ describe('createFixtureApi', () => { it('workspace.delete removes only the Workspace row and emits the removal 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) - abort.abort() - } - })() + const consuming = collectValues( + api.workspaceRemote.follow(abort.signal), + abort, + frames => frames.some(frame => frame.type === 'remove'), + ) await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) - await consuming - expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }]) - const list = await api.workspace.list(req({})) - if (!list.result.ok) throw new Error('workspace list failed') - expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false) + const frames = await consuming + expect(frames.at(-1)).toEqual({ type: 'remove', workspaceId: 'fx-ws-fixture' }) + const baseline = await readWorkspaceBaseline(api.workspaceRemote) + expect(baseline.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false) const sessions = await api.sessions.list(req({})) if (!sessions.result.ok) throw new Error('session list failed') expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha') @@ -707,14 +1208,23 @@ describe('createFixtureApi', () => { it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() - const abort = new AbortController() - const seen: HostFrame[] = [] + const hostAbort = new AbortController() + const workspaceAbort = new AbortController() + const seen: FixtureRemoteEventNotificationFrame[] = [] const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) { - seen.push(envelope.payload) - if (seen.length >= 2) abort.abort() + for await (const frame of api.remoteEvents(hostAbort.signal)) { + if (frame.type !== 'emit' || frame.event !== 'api-session/added') continue + seen.push(frame) + hostAbort.abort() + break } })() + const workspaceFrames = collectValues( + api.workspaceRemote.follow(workspaceAbort.signal), + workspaceAbort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.sessionIds.length === 4), + ) 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' } } }) @@ -722,30 +1232,44 @@ describe('createFixtureApi', () => { 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. const added = seen[0] - if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') - expect(added).toEqual({ - type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture', + expect(added).toMatchObject({ + event: 'api-session/added', + args: [{ 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'] }, + expect((await workspaceFrames).at(-1)).toMatchObject({ + type: 'upsert', + 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 () => { + it('supports an empty baseline, preallocated ids, independent streams, 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: [] } }) + expect(await readWorkspaceBaseline(api.workspaceRemote)).toEqual({ + items: [], + archivedSessionIds: [], + }) const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/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) + const hostAbort = new AbortController() + const workspaceAbort = new AbortController() + const hostFrames = collectValues( + api.remoteEvents(hostAbort.signal), + hostAbort, + frames => frames.length === 1, + ) + const workspaceFrames = collectValues( + api.workspaceRemote.follow(workspaceAbort.signal), + workspaceAbort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.sessionIds.includes(sid('fx-preallocated'))), + ) await new Promise(resolve => setTimeout(resolve, 10)) const preallocated = sid('fx-preallocated') const created = await api.sessions.create(req({ @@ -753,15 +1277,17 @@ describe('createFixtureApi', () => { 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((await workspaceFrames).at(-1)).toMatchObject({ + type: 'upsert', workspace: { sessionIds: [preallocated] }, }) - const added = frames[1] - if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') - expect(added).toEqual({ - type: 'host/session-added', sessionId: preallocated, blank: true, - cwd: made.result.value.workspace.path, + const added = (await hostFrames)[0] + expect(added).toMatchObject({ + event: 'api-session/added', + args: [{ + sessionId: preallocated, + blank: true, + cwd: made.result.value.workspace.path, + }], }) const retried = await api.sessions.create(req({ @@ -792,9 +1318,8 @@ describe('createFixtureApi', () => { 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) + const workspaces = await readWorkspaceBaseline(api.workspaceRemote) + expect(workspaces.items[0]?.sessionIds).toContain(sessionId) }) it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => { @@ -828,10 +1353,10 @@ describe('createFixtureApi', () => { 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') + const workspaces = await readWorkspaceBaseline(api.workspaceRemote) + if (!listed.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) + expect(workspaces.items[0]?.sessionIds).not.toContain(sessionId) const retried = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId, @@ -851,10 +1376,10 @@ describe('createFixtureApi', () => { 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') + const workspaces = await readWorkspaceBaseline(dropped.workspaceRemote) + if (!listed.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) + expect(workspaces.items[0]?.sessionIds).toContain(sessionId) await expect(dropped.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId, @@ -891,15 +1416,38 @@ describe('createFixtureApi', () => { // The failure was one-shot: the next call succeeds. const ok = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 })) expect(ok.result.ok).toBe(true) - // appendUser emits on the mux stream; appendSilent only lands in the log (lost frame). - const abort = new AbortController() - const seen: MuxFrame[] = [] - const consuming = (async () => { - for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push(envelope.payload) - })() - await new Promise(resolve => setTimeout(resolve, 10)) + // A durable append without a live frame creates a detectable seq gap. + const gapAbort = new AbortController() + const gapIterator = api.sessionRemote.follow(sid('fx-alpha'), gapAbort.signal)[Symbol.asyncIterator]() + const opening = await gapIterator.next() + if (opening.done || opening.value.type !== 'opened') throw new Error('follow opening cursor missing') + const resumeCursor = opening.value.cursor hooks.appendSilent('fx-alpha', '静默丢帧') hooks.appendUser('fx-alpha', '正常直播') + await expect(gapIterator.next()).rejects.toThrow(/stream skipped seq/) + + // Reopening from the established cursor replays both durable events. + const followAbort = new AbortController() + const controlAbort = new AbortController() + const followed: FixtureFollowFrame[] = [] + const controlled: FixtureControlFrame[] = [] + const following = (async () => { + for await (const frame of api.sessionRemote.follow( + sid('fx-alpha'), + followAbort.signal, + resumeCursor, + )) followed.push(frame) + })() + const controlling = (async () => { + for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlled.push(frame) + })() + await new Promise(resolve => setTimeout(resolve, 10)) + await vi.waitFor(() => { + expect(followed.some(frame => frame.type === 'event' + && JSON.stringify(frame.event.data).includes('静默丢帧'))).toBe(true) + expect(followed.some(frame => frame.type === 'event' + && JSON.stringify(frame.event.data).includes('正常直播'))).toBe(true) + }) hooks.appendTitle('fx-alpha', 'Fixture 修订标题') hooks.beginModelRetry('fx-alpha') hooks.scheduleModelRetry('fx-alpha') @@ -907,33 +1455,28 @@ describe('createFixtureApi', () => { hooks.beginModelRetry('fx-alpha') hooks.cancelModelRetryDuringBackoff('fx-alpha') await vi.waitFor(() => { - expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true) - expect(seen.some(f => f.type === 'session/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true) - expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true) - expect(seen.some(f => f.type === 'session/event' - && f.event.type === 'turn/end' - && f.event.data.reason.kind === 'aborted')).toBe(true) - expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true) + expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('正常直播'))).toBe(true) + expect(followed.some(frame => frame.type === 'event' && (frame.event as { type: string }).type === 'llm/retry')).toBe(true) + expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('重试后的完整回复'))).toBe(true) + expect(followed.some(frame => frame.type === 'event' + && frame.event.type === 'turn/end' + && frame.event.data.reason.kind === 'aborted')).toBe(true) + expect(controlled.some(frame => frame.type === 'projection' + && frame.key === 'title' + && frame.value === 'Fixture 修订标题')).toBe(true) }) - expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false) - const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title') - const titleControlIndex = seen.findIndex(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题') - expect(titleControlIndex).toBe(rawTitleIndex + 1) - // But history serves the silent event (the client's repull finds it). + expect(followed.some(frame => frame.type === 'event' && (frame.event as { type: string }).type === 'session/title')).toBe(true) + // Paging and resumed follow agree on the recovered durable event. const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 })) if (!repull.result.ok) throw new Error('repull failed') expect(JSON.stringify(repull.result.value.events)).toContain('静默丢帧') - // breakStreams force-ends BOTH stream kinds without the client abort. - const habort = new AbortController() - const hostConsuming = (async () => { - for await (const _ of api.events.host(req({}), habort.signal)) { /* drain */ } - })() + // breakStreams force-ends follow and control without client aborts. await new Promise(resolve => setTimeout(resolve, 10)) hooks.breakStreams() - await consuming // returns because the stream broke, not because we aborted - await hostConsuming - expect(abort.signal.aborted).toBe(false) - expect(habort.signal.aborted).toBe(false) + await following + await controlling + expect(followAbort.signal.aborted).toBe(false) + expect(controlAbort.signal.aborted).toBe(false) }) it('paces the opt-in reasoning stress hook from an external interval', async () => { @@ -947,8 +1490,8 @@ describe('createFixtureApi', () => { expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 0)).toThrow(/reasoning interval/) const abort = new AbortController() try { - const streamed = collect(api.events.mux(req({}), abort.signal), abort, frames => frames.some(frame => ( - frame.type === 'session/event' + const streamed = collectValues(api.sessionRemote.follow(sid('fx-alpha'), abort.signal), abort, frames => frames.some(frame => ( + frame.type === 'event' && frame.event.type === 'assistant/chunk' && frame.event.data.chunk.type === 'reasoning-delta' && frame.event.data.chunk.text.includes('REASONING_STRESS_COMPLETE') @@ -967,7 +1510,7 @@ describe('createFixtureApi', () => { const frames = await streamed const deltas = frames.flatMap(frame => ( - frame.type === 'session/event' + frame.type === 'event' && frame.event.type === 'assistant/chunk' && frame.event.data.chunk.type === 'reasoning-delta' ? [frame.event.data.chunk.text] @@ -993,18 +1536,16 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect(() => (client as unknown as { doFetch(): Promise }).doFetch()).toThrow(/doFetch must be unreachable/) }) - it('mints request ids, taps all four full forms, and never touches doFetch', async () => { + it('mints request ids and taps unary request/response envelopes without touching doFetch', async () => { const client = new FixtureApiClient() const tapped: RpcMessage[] = [] client.subscribeEnvelopes(batch => tapped.push(...batch)) - const response = await client.sessions.list({}) + const response = await client.host.describe({}) expect(response.result.ok).toBe(true) - await client.respond({ type: 'client-response', rpcId: RpcId('r-x'), result: { ok: true, value: {} } }) await vi.waitFor(() => { const kinds = tapped.map(m => m.type) expect(kinds).toContain('client-request') expect(kinds).toContain('server-response') - expect(kinds).toContain('client-response') }) const request = tapped.find(m => m.type === 'client-request') const reply = tapped.find(m => m.type === 'server-response') @@ -1013,28 +1554,30 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { it('covers the whole unary dispatch table', async () => { const client = new FixtureApiClient() - expect((await client.sessions.search( + const sessions = createSessionClient(client.rpc) + const workspaces = createWorkspaceClient(client.rpc) + expect((await sessions.search( { query: 'fixture' }, new AbortController().signal, )).result.ok).toBe(true) - const created = await client.sessions.create({}) + const created = await sessions.create({}) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId - expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true) - 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 sessions.history({ sessionId: id })).result.ok).toBe(true) + expect((await sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) + expect((await 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({ path: '/tmp/fixture-workspaces/via-client' }) + expect((await readWorkspaceBaseline(createWorkspaceRemote(client.rpc))).items).not.toHaveLength(0) + const workspace = await workspaces.create({ path: '/tmp/fixture-workspaces/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' }) + const renamed = await workspaces.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 }) + const attached = await 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 }) + const moved = await workspaces.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]) // Goal lifecycle over the fixture fold: create → edit → pause → resume → complete → clear; @@ -1061,7 +1604,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false) expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } }) - const goalHistory = await client.sessions.history({ sessionId: id }) + const goalHistory = await sessions.history({ sessionId: id }) if (!goalHistory.result.ok) throw new Error('goal history failed') const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as { type: string @@ -1082,21 +1625,40 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { 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({ path: '/tmp/fixture-workspaces/query-workspace' }) + const sessions = createSessionClient(client.rpc) + const workspaces = createWorkspaceClient(client.rpc) + const workspaceRemote = createWorkspaceRemote(client.rpc) + await expect(sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) + const made = await workspaces.create({ path: '/tmp/fixture-workspaces/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) + const hostAbort = new AbortController() + const workspaceAbort = new AbortController() + const hostFrames = collectValues( + openFixtureRemoteEvents(client.rpc, hostAbort.signal), + hostAbort, + frames => frames.length === 1, + ) + const workspaceFrames = collectValues( + workspaceRemote.follow(workspaceAbort.signal), + workspaceAbort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.sessionIds.includes(sid('fx-query-session'))), + ) await new Promise(resolve => setTimeout(resolve, 10)) const sessionId = sid('fx-query-session') - const created = await client.sessions.create({ + const created = await 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({ + expect((await workspaceFrames).at(-1)).toMatchObject({ + type: 'upsert', + workspace: { sessionIds: [sessionId] }, + }) + expect((await hostFrames)[0]).toMatchObject({ + event: 'api-session/added', + }) + const rejected = await sessions.prompt({ sessionId, mode: 'queue', content: [{ type: 'text', text: 'retain' }], @@ -1107,7 +1669,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { 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({ + const partialResult = await createSessionClient(partial.rpc).create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-partial'), }) @@ -1118,34 +1680,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' }) const dropped = new FixtureApiClient() - await expect(dropped.sessions.create({ + await expect(createSessionClient(dropped.rpc).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 () => { - const client = new FixtureApiClient() - const tapped: RpcMessage[] = [] - client.subscribeEnvelopes(batch => tapped.push(...batch)) - const order: string[] = [] - const abort = new AbortController() - for await (const envelope of client.events.mux({}, abort.signal, () => order.push('open'))) { - order.push(envelope.payload.type) - abort.abort() - } - expect(order[0]).toBe('open') - expect(order[1]).toBe('session/subscribed') - await vi.waitFor(() => { - expect(tapped.some(m => m.type === 'server-request')).toBe(true) - }) - // Host stream side of the pair (same tap path). - const habort = new AbortController() - const hostOrder: string[] = [] - const hostIterator = client.events.host({}, habort.signal, () => hostOrder.push('open'))[Symbol.asyncIterator]() - const raced = await Promise.race([hostIterator.next(), new Promise<'idle'>(resolve => setTimeout(() => { resolve('idle') }, 50))]) - expect(hostOrder).toEqual(['open']) // established even though the host stream stays silent - habort.abort() - if (raced === 'idle') await hostIterator.return?.(undefined) - }) }) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 022436d558..504e50d9fd 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -1,7 +1,7 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ -import { EventEmitter, once } from 'node:events' +import { EventEmitter } from 'node:events' import { createServer, request as httpRequest } from 'node:http' -import { PassThrough, Readable } from 'node:stream' +import { Readable } from 'node:stream' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' @@ -10,7 +10,7 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts' +import { API_PATH, apply, inject, type HostConnectionHandle } from '../src/index.ts' import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' /** Structural webServer fake recording both route registries. */ @@ -78,6 +78,7 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[] upgrades: WebUpgradeRoute[] + connection: HostConnectionHandle dispose: () => Promise }> { const ctx = new Context() @@ -87,7 +88,12 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() - return { routes, upgrades, dispose: () => fiber.dispose() } + return { + routes, + upgrades, + connection: ctx.get('connection') as HostConnectionHandle, + dispose: () => fiber.dispose(), + } } describe('connection node half', () => { @@ -121,41 +127,16 @@ describe('connection node half', () => { expect(upgrades).toHaveLength(0) }) - it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => { + it('registers only the HTTP route and removes it with the fiber', async () => { const { routes, upgrades, dispose } = await mounted() expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) - expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH]) + expect(upgrades).toHaveLength(0) await dispose() expect(routes).toHaveLength(0) expect(upgrades).toHaveLength(0) }) - it('requires WebSocket upgrade for network GETs to either event path', async () => { - const { routes, dispose } = await mounted() - for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) { - const { response, state } = fakeResponse() - await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response) - expect(state.status).toBe(426) - expect(state.body).toBe('upgrade required') - } - await dispose() - }) - - it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => { - const { upgrades, dispose } = await mounted() - const socket = new PassThrough() - const chunks: Buffer[] = [] - socket.on('data', (chunk: Buffer) => { chunks.push(chunk) }) - const ended = once(socket, 'end') - await upgrades[0]!.handler(fakeRequest({ - host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', - }, MUX_EVENTS_PATH), socket, Buffer.alloc(0)) - await ended - expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden') - await dispose() - }) - it('refuses an untrusted Host on any /api path before the bridge runs', async () => { const { routes, dispose } = await mounted() const { response, state } = fakeResponse() @@ -219,6 +200,17 @@ describe('connection node half', () => { await dispose() }) + it('shares its configured trust policy with sibling routes', async () => { + const { connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) + const loopback = fakeRequest({ host: '127.0.0.1:3080' }) + const declared = fakeRequest({ host: 'harness.example' }) + + expect(connection.isTrustedRequest(loopback, 'loopback')).toBe(true) + expect(connection.isTrustedRequest(declared, 'loopback')).toBe(false) + expect(connection.isTrustedRequest(declared, 'trusted-host')).toBe(true) + await dispose() + }) + it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => { const ctx = new Context() const routes: WebRoute[] = [] diff --git a/packages/client/connection/tests/websocket-downlink.host.spec.ts b/packages/client/connection/tests/websocket-downlink.host.spec.ts deleted file mode 100644 index fecd7ea224..0000000000 --- a/packages/client/connection/tests/websocket-downlink.host.spec.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { once } from 'node:events' -import { createServer } from 'node:http' -import type { AddressInfo } from 'node:net' -import { afterEach, describe, expect, it, vi } from 'vitest' -import WebSocket from 'ws' -import type { - ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest, -} from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' -import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../src/api-path.ts' -import { WebSocketDownlinks } from '../src/websocket-downlink.ts' - -type MuxSource = (signal: AbortSignal) => AsyncIterable> -type HostSource = (signal: AbortSignal) => AsyncIterable> - -const running: (() => Promise)[] = [] - -afterEach(async () => { - await Promise.all(running.splice(0).map(close => close())) -}) - -function untilAbort(signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) -} - -async function * idle(signal: AbortSignal): AsyncGenerator> { - await untilAbort(signal) -} - -function api(mux: MuxSource, host: HostSource): ApiProxy { - return { - events: { - mux: (_request, signal) => mux(signal), - host: (_request, signal) => host(signal), - }, - } as ApiProxy -} - -async function serve(downlinks: WebSocketDownlinks): Promise<{ - origin: string - close: () => Promise -}> { - const server = createServer() - server.on('upgrade', (request, socket, head) => { - const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname - if (pathname === MUX_EVENTS_PATH) downlinks.handleMux(request, socket, head) - else if (pathname === HOST_EVENTS_PATH) downlinks.handleHost(request, socket, head) - else socket.destroy() - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const port = (server.address() as AddressInfo).port - return { - origin: `ws://127.0.0.1:${String(port)}`, - close: async () => { - await downlinks.close() - await new Promise(resolve => server.close(() => { resolve() })) - }, - } -} - -function read(socket: WebSocket): Promise { - return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest) -} - -async function acceptedSocket(downlinks: WebSocketDownlinks): Promise { - const server = (downlinks as unknown as { server: { clients: Set } }).server - let accepted: WebSocket | undefined - await vi.waitFor(() => { - accepted = server.clients.values().next().value - expect(accepted).toBeDefined() - }) - return accepted as WebSocket -} - -describe('WebSocket downlinks', () => { - it('carries mux and host over independent downstream sockets and cancels each source on close', async () => { - let muxAborted = false - let hostAborted = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - yield { - rpcId: RpcId('mux-1'), - payload: { type: 'session/subscribed', sessionId: 'session-1' as never, lastSeq: 4 }, - } - await untilAbort(signal) - } finally { - muxAborted = true - } - }, - async function * (signal) { - try { - yield { rpcId: RpcId('host-1'), payload: { type: 'host/remote-event', event: 'commands/change', args: [] } } - await untilAbort(signal) - } finally { - hostAborted = true - } - }, - )) - const host = await serve(downlinks) - running.push(host.close) - - const mux = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - const hostSocket = new WebSocket(`${host.origin}${HOST_EVENTS_PATH}`) - const muxFrame = read(mux) - const hostFrame = read(hostSocket) - expect(await muxFrame).toEqual({ - type: 'server-request', - rpcId: 'mux-1', - method: 'session/subscribed', - payload: { type: 'session/subscribed', sessionId: 'session-1', lastSeq: 4 }, - }) - expect(await hostFrame).toEqual({ - type: 'server-request', - rpcId: 'host-1', - method: 'host/remote-event', - payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, - }) - - const muxClosed = once(mux, 'close') - const hostClosed = once(hostSocket, 'close') - mux.close() - hostSocket.close() - await Promise.all([muxClosed, hostClosed]) - await vi.waitFor(() => { - expect(muxAborted).toBe(true) - expect(hostAborted).toBe(true) - }) - }) - - it('rejects client messages because upstream remains HTTP', async () => { - let aborted = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - await untilAbort(signal) - } finally { - aborted = true - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const closed = once(socket, 'close') - socket.send('upstream payload') - const [code, reason] = await closed as [number, Buffer] - expect(code).toBe(1008) - expect(String(reason)).toBe('downlink only') - await vi.waitFor(() => { expect(aborted).toBe(true) }) - }) - - it('sends stream/error before closing when a source fails', async () => { - const downlinks = new WebSocketDownlinks(api( - async function * () { - throw new Error('mux source failed') - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - const failure = read(socket) - const closed = once(socket, 'close') - expect((await failure).payload).toEqual({ - type: 'stream/error', - error: { code: 'internal', message: 'Error: mux source failed', details: {} }, - }) - await closed - }) - - it('aborts the source when an accepted socket reports a transport error', async () => { - let aborted = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - await untilAbort(signal) - } finally { - aborted = true - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const accepted = await acceptedSocket(downlinks) - const closed = once(socket, 'close') - accepted.emit('error', new Error('transport failed')) - await closed - expect(aborted).toBe(true) - }) - - it('drops a source frame that races after the client has closed', async () => { - let release!: () => void - const gate = new Promise((resolve) => { release = resolve }) - let finish!: () => void - const finished = new Promise((resolve) => { finish = resolve }) - let sourceSignal: AbortSignal | undefined - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - sourceSignal = signal - try { - await gate - yield { - rpcId: RpcId('late'), - payload: { type: 'session/subscribed', sessionId: 'session-late' as never, lastSeq: 0 }, - } - } finally { - finish() - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const closed = once(socket, 'close') - socket.close() - await closed - await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) }) - release() - await finished - }) - - it('contains socket send callback failures and closes the downlink', async () => { - let release!: () => void - const gate = new Promise((resolve) => { release = resolve }) - const downlinks = new WebSocketDownlinks(api( - async function * () { - await gate - yield { - rpcId: RpcId('send-failure'), - payload: { type: 'session/subscribed', sessionId: 'session-send' as never, lastSeq: 0 }, - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const accepted = await acceptedSocket(downlinks) - const send = vi.spyOn(accepted, 'send').mockImplementation((( - _data: unknown, - optionsOrCallback?: unknown, - callback?: (error?: Error) => void, - ) => { - const done = typeof optionsOrCallback === 'function' - ? optionsOrCallback as (error?: Error) => void - : callback - done?.(new Error('socket send failed')) - }) as WebSocket['send']) - const closed = once(socket, 'close') - release() - await closed - expect(send).toHaveBeenCalledTimes(2) - send.mockRestore() - }) - - it('rejects when its acceptor has already closed', async () => { - const downlinks = new WebSocketDownlinks(api(idle, idle)) - await downlinks.close() - await expect(downlinks.close()).rejects.toThrow('The server is not running') - }) - - it('waits for source cleanup before teardown resolves', async () => { - let cleanupStarted!: () => void - const started = new Promise((resolve) => { cleanupStarted = resolve }) - let releaseCleanup!: () => void - const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve }) - let cleaned = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - await untilAbort(signal) - } finally { - cleanupStarted() - await cleanupGate - cleaned = true - } - }, - idle, - )) - const host = await serve(downlinks) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - let closed = false - const closing = host.close().then(() => { closed = true }) - try { - await started - expect(closed).toBe(false) - releaseCleanup() - await closing - expect(cleaned).toBe(true) - } finally { - releaseCleanup() - await closing - } - }) -}) diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index 3bce9617e6..b1a6bc2805 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -45,11 +45,10 @@ async function bench() { } }) ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never) - // The settings transport and the forwarded-event port the plugin injects. - new TestRemote(ctx) + const events = new TestRemote(ctx) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { - ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, + ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, events, setHostPreference: (next: string | undefined) => { preference = next; revision += 1 }, } } @@ -136,19 +135,19 @@ describe('locale apply', () => { // Preference must differ from the provisional locale (FALLBACK_LOCALE = en // with no window), or clearing it below would be unobservable. b.setHostPreference('zh') - b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const locale = b.ctx.get('locale') as LocaleRuntime await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) // Cleared preference falls back to the provisional locale. b.setHostPreference(undefined) - b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) // Re-selecting zh after the clear is an explicit pick of the provisional // value and must persist as a written preference. b.setHostPreference('zh') - b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) expect(b.describe).toHaveBeenCalledTimes(4) }) diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index b1078e71ca..8e88ef6ebc 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -18,11 +18,12 @@ import { Context as CordisContext } from '@deepseek-ai/cordis' import type { Context, Fiber } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import type { TypertClientRemote, TypertRemoteScopeApi } from '@deepseek-ai/dsh-typert-protocol' +import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { TypertRemoteScopeApi } from '@deepseek-ai/dsh-typert-protocol' /** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ export type AgentContext = Omit & { - readonly remote: TypertClientRemote & TypertRemoteScopeApi<'agent'> + readonly remote: ClientRemote & TypertRemoteScopeApi<'agent'> } /** Context tag written by {@link createScope}. */ diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts index f14fb02d7c..eff765728c 100644 --- a/packages/client/runtime/src/client/contract/conversation.ts +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -1,5 +1,5 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { ToolEventView } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types' /* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents -- * The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program; @@ -8,7 +8,7 @@ import type { ToolEventView } from '@deepseek-ai/dsh-api-remotes/client' /** One raw log event plus its optional envelope-level presentation view. */ export interface ConversationEventInput { readonly event: SessionEvent - readonly view: ToolEventView | undefined + readonly view: SessionToolView | undefined } /** Definition-local identity and lifecycle role extracted from one event. */ diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index e07267d487..aff25a1478 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -9,7 +9,7 @@ */ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { - MessageId, PromptContentPart, QueueAction, RpcResult, SessionId, + ClientResult, MessageId, PromptContentPart, QueueAction, SessionId, } from '@deepseek-ai/dsh-api-remotes/client' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ConversationSnapshot } from '../sessions/conversation.ts' @@ -42,7 +42,7 @@ export interface ISession { content: PromptContentPart[], mode: 'queue' | 'steer', signal?: AbortSignal, - ): Promise> + ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. @@ -50,27 +50,27 @@ export interface ISession { */ readAttachment( attachmentId: AttachmentIdType, - ): Promise> + ): Promise> /** * Apply one edit, remove, or strict steer action to a still-pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. * @param action - requested queue operation. * @returns acceptance, or a business/transport error. */ - updateQueue(itemId: MessageId, action: QueueAction): Promise> + updateQueue(itemId: MessageId, action: QueueAction): Promise> /** * Cancel the running turn. Pending queued work remains and resumes in FIFO * order after the Host reaches cancellation quiescence. * @returns acceptance, or the business error. */ - cancel(): Promise> + cancel(): Promise> /** * Rename this session (explicit user title; pins it against automatic * regeneration). * @param title - raw title text (the host normalizes acceptance). * @returns the normalized accepted title and its event seq, or the business error. */ - rename(title: string): Promise> + rename(title: string): Promise> /** * Extend the history window backwards (older messages pagination). * @returns completion; failures land in snapshot.openState/loadingOlder. diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 27b9d2d13b..ca53adf037 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -1,15 +1,15 @@ /** * The outward sessions-service face — what `ctx.sessions` exposes to feature * packages and the renderer host, and therefore exactly what the test - * runtime's sessions double must implement. Wire-pump entry points - * (handleMuxEnvelope/handleConnected/refresh) and runtime internals stay on + * runtime's sessions double must implement. Transport entry points and + * runtime internals stay on * the concrete class; cross-domain consumers keep the narrower * [SessionsPort](./sessions-port.ts). Widening this interface is the * explicit act of widening what features may do to the sessions domain. */ import type { Context } from '@deepseek-ai/cordis' import type { - RpcResult, SessionId, SubagentAddress, + ClientResult, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-api-remotes/client' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import type { AgentContext } from '../agents/scope.ts' @@ -83,7 +83,7 @@ export interface ISessions { search( query: string, signal: AbortSignal, - ): Promise> + ): Promise> /** * Fork a session from a completed-turn prefix of the source; on resolution * the child is in the list store and `open()` can target it. diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index d5d6333e14..5fe785dc51 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,6 +1,13 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { + createSessionControlStream, + SESSION_SEARCH_RESULT_LIMIT, +} from '@deepseek-ai/dsh-api-session-controller/client' +import { + createWorkspaceStateStream, ClientWorkspaceModel, +} from '@deepseek-ai/dsh-api-workspace-controller/client' // Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather // than api-remotes': that face imports a Host-tsdown-generated artifact, and this // project sits in the Host build graph. @@ -17,6 +24,7 @@ import { ConversationEventRegistry } from './conversation/event-registry.ts' import { ConversationViewRegistry } from './conversation/view-registry.ts' export { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +export { SESSION_SEARCH_RESULT_LIMIT } export { SlotRegistry } from './slots.ts' export { ConversationEventRegistry } from './conversation/event-registry.ts' @@ -58,12 +66,12 @@ export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, } from './sessions/service.ts' export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts' -export type { SubagentAddress, JobView } from '@deepseek-ai/dsh-client-connection/client' -export type { WorkspaceListPhase } from './workspaces/manager.ts' +export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' +export type { WorkspaceListPhase } from '@deepseek-ai/dsh-api-workspace-controller/client' export type { WorkspaceListState } from './workspaces/service.ts' -export type { - DirectoryEntry, DirectoryListing, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-client-connection/client' +export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client' // Runtime owns the snapshot store; ui-renderer only binds it to React. export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' export type { @@ -158,8 +166,8 @@ declare module '@deepseek-ai/cordis' { 'slots/changed'(key: string): void /** * A connection generation was (re-)established. Wire-derived caches must - * treat their state as stale and repull (commands directory; the queue - * mirrors reset themselves through the session resync path). + * treat their state as stale and repull. Session follow and control + * streams own their independent resume and baseline lifecycles. * @mode emit */ 'connection/reset'(): void @@ -178,7 +186,14 @@ declare module '@deepseek-ai/cordis' { } /** Required services: the wire handle and Client Typert registry. */ -export const inject = ['connection', 'typert', 'remote', 'remote.commands'] +export const inject = [ + 'connection', + 'typert', + 'remote', + 'remote.commands', + 'remote.session', + 'remote.workspace', +] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. @@ -191,41 +206,44 @@ export function apply(ctx: Context): void { } const connection = ctx.get('connection') as ConnectionHandle const sessions = new SessionRuntime(ctx, connection.api, ctx.remote, conversation) + ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) + ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) + ctx.remote.$on('api-session/status', (sessionId, running) => { + sessions.handleSessionStatus(sessionId, running) + }) + ctx.remote.$on('api-session/activity', (sessionId, updatedAt) => { + sessions.handleSessionActivity(sessionId, updatedAt) + }) + ctx.remote.$on('api-session/error', (sessionId, message) => { + sessions.handleSessionError(sessionId, message) + }) + const sessionControl = createSessionControlStream(ctx.remote, { + accept: (frame) => { sessions.handleControlFrame(frame) }, + failed: (error) => { console.error('[web-runtime] session control stream failed:', error) }, + }) + sessionControl.start() ctx.typert.contexts.registerClient('agent', { identity: candidate => sessions.scopeOf(candidate), + resolve: sessionId => sessions.scope(sessionId), }) - const workspaces = new WorkspaceRuntime(ctx, connection.api, sessions) + const workspaceModel = new ClientWorkspaceModel(ctx.remote.workspace) + const workspaces = new WorkspaceRuntime(ctx, connection.api, workspaceModel, sessions) + const workspaceControl = createWorkspaceStateStream(ctx.remote, { + accept: workspaceModel, + carrierFailed: () => { workspaceModel.handleCarrierFailure() }, + failed: (error) => { workspaceModel.handleStreamFailure(error) }, + }) + workspaceControl.start() ctx.effect( () => workspaces.startInitialSelection(), 'runtime: initial Workspace selection', ) - const loop = connection.start({ - onMuxEnvelope: (envelope) => { - sessions.handleMuxEnvelope(envelope) - }, - onHostEnvelope: (envelope) => { - sessions.handleHostEnvelope(envelope) - workspaces.handleHostEnvelope(envelope) - // Forwarded-event bridge: the session layer ignores registry frames (no - // session routing). This plugin owns the frame sink, so it hands the - // decoded frame straight to the Remote service, which fans it out to - // `ctx.remote.$on` subscribers; no consumer reads a frame. - const frame = envelope.payload - if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args) - }, - onConnected: () => { - sessions.handleConnected() - workspaces.handleConnected() - ctx.emit('connection/reset') - }, - onStateChange: (state) => { - // Generation death fires before any next-generation frame can arrive - // (reconnect replays flow from stream open, ahead of onConnected): - // the only safe moment to drop generation-scoped interaction state. - if (state === 'reconnecting') { - sessions.handleDisconnected() - } - }, - }) - ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') + ctx.on('connection/reset', () => { sessions.handleConnected() }) + if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected() + ctx.effect(() => async () => { + await Promise.all([ + workspaceControl.dispose(), + sessionControl.dispose(), + ]) + }, 'runtime: connection streams') } diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index daece0addb..3f96119ad8 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -9,7 +9,7 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' import type { - RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView, + ClientFailure, SessionId, SubagentAddress, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-api-remotes/client' import type { PendingInteraction } from './pending.ts' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' @@ -310,7 +310,7 @@ export interface RunningToolCall { /** One running or settled call, recursively owning its child calls. */ export type ToolCallBlock = RunningToolCall | ToolResultNode -/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */ +/** One transient inbox occurrence from the Session control stream's queue snapshot. */ export interface QueuedMessage { readonly id: MessageId /** Stable message identity used for transient-to-durable steering handoff. */ @@ -358,7 +358,7 @@ export type ComposerPhase = 'blank' | 'engaging' | 'active' /** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */ export interface PromptError { op: 'send' | 'stop' - error: RpcError + error: ClientFailure } /** @@ -456,20 +456,20 @@ export interface ConversationSnapshot { subagent: { address: SubagentAddress; parentAvailable: boolean } | null /** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */ composerPhase: ComposerPhase - /** Set after host/session-removed; the UI grays out and disables input. */ + /** Set after the forwarded `api-session/removed` event; the UI disables input. */ removed: boolean openState: OpenState - openError: RpcError | null + openError: ClientFailure | null hasMore: boolean loadingOlder: boolean promptError: PromptError | null /** * Whether this session still has an empty log (no user message yet). - * Mirrors the host summary's derived blank bit: seeded from `session.list` - * / the `host/session-added` frame, flipped false by the first ACCEPTED + * Mirrors the Host summary's derived blank bit: seeded from `session.list` + * or `api-session/added`, flipped false by the first accepted * prompt locally (on the RPC success response — acceptance proves the * user message is in the host log; a rejected first prompt keeps the - * session blank and reusable) and by any `running: true` status remotely, + * Session blank and reusable) and by any remote `running: true` status, * and re-aligned by every list re-pull (the summary stays authoritative). * Blank sessions are hidden from session lists and reused by New Session. */ diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 7579310f49..d83ce1229e 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -6,7 +6,7 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/cli import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { PendingInteractionStatus } from './pending.ts' -/** Host list summary enriched with the latest mux-projected durable title. */ +/** Host list summary enriched with the latest Session Controller title projection. */ export interface TitledSessionSummary extends SessionSummary { title?: string /** Current host-computed projection values for list consumers. */ @@ -29,7 +29,7 @@ export interface SessionListEntry { agentPreset?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> - /** User interaction currently blocking this session, derived from live mux frames. */ + /** User interaction currently blocking this session, derived from live control frames. */ pendingInteraction?: PendingInteractionStatus /** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */ completed: boolean diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 13aa20d1c8..f9e44b07a6 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -3,9 +3,18 @@ // List data never enters zustand; React connects via subscribe/getListSnapshot. import type { - IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, + ClientFailure, ClientResult, IApiClient, SessionId, SessionSummary, SubagentAddress, SubagentCatalog, JobView, WorkspaceId, } from '@deepseek-ai/dsh-api-remotes/client' +import type { + SessionApprovalRequest, + SessionControlBaseline, + SessionControlFrame, + SessionInteractionId, + SessionQuestionRequest, + SessionQueuedItem, + SessionError, +} from '@deepseek-ai/dsh-api-session-controller/types' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -47,7 +56,7 @@ export interface SessionListSnapshot { state: 'idle' | 'loading' | 'error' /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */ phase: SessionListPhase - error: RpcError | null + error: ClientFailure | null subagentsByParent: Readonly> /** Background jobs per session; an absent key is an empty set. */ jobsBySession: Readonly> @@ -57,7 +66,7 @@ export interface SessionListSnapshot { /** One parent-addressed durable catalog projected through the sessions snapshot. */ export interface SubagentCatalogSnapshot extends SubagentCatalog { state: 'loading' | 'ready' | 'error' - error: RpcError | null + error: ClientFailure | null } interface CatalogInflight { @@ -76,21 +85,9 @@ type SessionListMutation = /** Local first-send flip: the sender clears blank without waiting for a host frame. */ | { kind: 'engaged'; sessionId: SessionId } -/** Stable identity of a frame retained until an uninstantiated Session can consume it. */ -function bufferedRequestKey(envelope: RpcRequest): string | undefined { - const frame = envelope.payload - switch (frame.type) { - case 'approval/requested': return `a:${frame.approvalId}` - case 'question/requested': return `q:${envelope.rpcId}` - case 'session/queue': return 'queue' - /* v8 ignore next -- pendingBuffers contains only the three frame types above. */ - default: return undefined - } -} - /** Match ui-user-questions's binary plan-review routing at the wire boundary. */ function questionInteractionStatus( - questions: Extract['questions'], + questions: SessionQuestionRequest['questions'], ): PendingInteractionStatus { if (questions.length !== 1) return 'question' const question = questions[0] as typeof questions[number] @@ -105,14 +102,17 @@ function questionInteractionStatus( /** Instance cluster + frame entry + the session list. */ export class SessionManager { private readonly sessions = new Map() - /** Pre-instantiation buffer for answerable requests and the queued-turn snapshot, which history - * cannot reconstruct on open. Live requests remain until resolution; queue and replay duplicates - * compact by identity. Instantiation replays and clears it, while removal drops it. */ - private readonly pendingBuffers = new Map[]>() + /** Latest transient queues, retained independently of Session object materialization. */ + private readonly queues = new Map() + /** Answerable requests retained by stable identity for lazy Session materialization. */ + private readonly interactions = new Map< + SessionId, + Map + >() /** Outstanding answerable interactions per session, keyed by their stable request identity. * Manager-owned rather than read off Session instances because the sidebar must light up for - * sessions never instantiated. Cleared per connection generation — the reopen replay re-adds - * still-pending requests — and on session-removed. */ + * sessions never instantiated. Each control baseline replaces the complete set, and + * session removal clears the corresponding rows. */ private readonly pendingInteractions = new Map>() /** * Sessions that finished running while not selected — the sidebar's green @@ -131,7 +131,7 @@ export class SessionManager { private listState: 'idle' | 'loading' | 'error' = 'idle' /** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */ private listPhase: SessionListPhase = 'pending' - private listError: RpcError | null = null + private listError: ClientFailure | null = null private listInflight: Promise | null = null /** Mutations arriving after a list request starts are replayed over its response. */ private listMutations: SessionListMutation[] | null = null @@ -143,8 +143,9 @@ export class SessionManager { private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() /** - * Background jobs per session, last-wins from `session/jobs`. An empty set - * is stored as an absent key, so absence and `[]` are one representation. + * Background jobs per session, last-wins from Session Controller's control + * stream. An empty set is stored as an absent key, so absence and `[]` are + * one representation. */ private readonly jobsBySession = new Map() @@ -274,16 +275,14 @@ export class SessionManager { if (session === undefined) { session = this.createSession(sessionId) this.sessions.set(sessionId, session) - // Replay approval/question/queued frames buffered before instantiation (rpcId - // verbatim, same semantics as the subscribed baseline replay). Replay happens - // BEFORE the running-bit sync: a not-running summary must sweep replayed queue + // Install the latest control baseline before the running-bit sync: a + // not-running summary must sweep replayed queue // rows the same way a live status flip would (their retirement events dropped // while the session was uninstantiated). - const buffered = this.pendingBuffers.get(sessionId) - if (buffered !== undefined) { - this.pendingBuffers.delete(sessionId) - for (const envelope of buffered) session.handleMuxEnvelope(envelope.rpcId, envelope.payload) - } + session.replaceControl( + this.queues.get(sessionId) ?? [], + [...(this.interactions.get(sessionId)?.values() ?? [])], + ) // Sync the running and blank bits from the list snapshot into the new // instance (consistency when the list precedes open). const summary = this.summaries.find(s => s.sessionId === sessionId) @@ -446,10 +445,10 @@ export class SessionManager { this.notifier.markDirty() this.listInflight = (async () => { try { - const { result } = await this.api.sessions.list({}) + const result = toSessionResult(await this.remote.session.list({})) if (result.ok) { - const baseline = this.listPhase === 'pending' - ? result.value.items + const baseline: SessionSummary[] = this.listPhase === 'pending' + ? [...result.value.items] : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId) // Seed first observations from the pull-time baseline BEFORE replaying // in-flight mutations, then reconcile the reminders after EVERY @@ -518,9 +517,17 @@ export class SessionManager { async search( query: string, signal: AbortSignal, - ): Promise> { + ): Promise> { try { - return (await this.api.sessions.search({ query }, signal)).result + const result = toSessionResult(await this.remote.session.search({ query }, signal)) + if (!result.ok) return result + return { + ok: true, + value: { + items: [...result.value.items], + hasMore: result.value.hasMore, + }, + } } catch (error: unknown) { return transportError(error) } @@ -532,16 +539,20 @@ export class SessionManager { * (entity birth precedes the first message). * @param opts - target workspace or working directory, plus an optional caller-owned id. * @returns the create result. - */ + */ async create( - opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}, - ): Promise> { + opts: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + } = {}, + ): Promise> { try { const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } const payload = opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId, ...shared } : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } - const { result } = await this.api.sessions.create(payload) + const result = toSessionResult(await this.remote.session.create(payload)) if (result.ok) { this.recordMutation({ kind: 'upsert', summary: { sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, @@ -579,13 +590,13 @@ export class SessionManager { */ async fork( opts: { sessionId: SessionId; atSeq?: number }, - ): Promise> { + ): Promise> { try { const source = this.summaries.find(s => s.sessionId === opts.sessionId) - const { result } = await this.api.sessions.fork({ + const result = toSessionResult(await this.remote.session.fork({ sessionId: opts.sessionId, ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }, - }) + })) const childId = result.ok ? result.value.sessionId : workspaceAttachSessionId(result.error) @@ -672,240 +683,195 @@ export class SessionManager { this.notifier.markDirty() } - // ---- ConnectionController sinks (wired by boot) ---- + // ---- Live control and Host-event sinks ---- /** - * Mux frame entry: sessionId-bearing frames go only to instantiated sessions - * (no lazy build; non-pending frames for uninstantiated sessions drop — - * history backfills them on open). - * @param envelope - the frame with its wire rpcId. + * Apply a complete control baseline or one later replacement frame. + * @param frame - baseline or live control replacement from Session Controller. */ - handleMuxEnvelope(envelope: RpcRequest): void { - const frame = envelope.payload - if (frame.type === 'stream/error') return // Controller already treats this as stream failure - if ( - frame.type === 'session/event' - && frame.event.type === 'user/message' - && frame.event.data.source.kind === 'user' - ) { - // session.list supplies the cold baseline, while a direct prompt or an - // admitted steer advances it between pulls. Max keeps replayed or - // repaired older user messages from moving the row backwards. - this.recordMutation({ kind: 'activity', sessionId: frame.sessionId, updatedAt: frame.event.time }) + handleControlFrame(frame: SessionControlFrame): void { + if (frame.type === 'baseline') { + this.replaceControlBaseline(frame.value) + return } - if (frame.type === 'session/projection') { - // Finished host-computed value: land it in the resident store whether or - // not the Session is instantiated (list rows read the 'title' key). The - // synchronous markDirty keeps the list snapshot same-tick fresh (the - // store's own any-key channel is microtask-batched). + if (frame.type === 'projection') { this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq) this.notifier.markDirty() return } - if (frame.type === 'session/jobs') { - // Whole-set snapshot, so last-wins with no reconciliation. The Host omits - // the baseline for an empty set, which is the same fact an emptying change - // reports as `[]` — both land as an absent key. + if (frame.type === 'jobs') { if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId) else this.jobsBySession.set(frame.sessionId, frame.jobs) this.notifier.markDirty() return } - if (frame.type === 'session/subscribed') { - // Rows past the host's durable baseline rode state a restart lost; drop - // them so last-wins cannot pin a phantom value over recomputed truth. - this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) - // Same re-baseline reasoning as the queue below: this generation sends a - // task baseline only when the set is non-empty, so a mirror kept from the - // previous generation would survive as a phantom list. - this.jobsBySession.delete(frame.sessionId) - this.notifier.markDirty() - // New mux-generation baseline: discard the previous queue snapshot. - // The host omits session/queue when the live queue is empty, so retaining - // it could replay stale work when the Session is instantiated later. - // This is the same re-baseline signal Session uses for its own mirror. - const buffered = this.pendingBuffers.get(frame.sessionId) - if (buffered !== undefined) { - const kept = buffered.filter(item => item.payload.type !== 'session/queue') - if (kept.length !== buffered.length) { - if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId) - else this.pendingBuffers.set(frame.sessionId, kept) - } + if (frame.type === 'queue') this.queues.set(frame.sessionId, frame.items) + if (frame.type === 'approval/requested' || frame.type === 'question/requested') { + let interactions = this.interactions.get(frame.sessionId) + if (interactions === undefined) { + interactions = new Map() + this.interactions.set(frame.sessionId, interactions) } - } - // List-level pending-interaction status (the sidebar amber dot): tracked - // for every session, instantiated or not; stable keys make replays idempotent. - if (frame.type === 'approval/requested') { - this.trackPending(frame.sessionId, `a:${frame.approvalId}`, 'approval') - } else if (frame.type === 'approval/resolved') { - this.resolvePending(frame.sessionId, `a:${frame.approvalId}`) - } else if (frame.type === 'question/requested') { + interactions.set(frame.interactionId, frame) this.trackPending( frame.sessionId, - `q:${envelope.rpcId}`, - questionInteractionStatus(frame.questions), + `${frame.type === 'approval/requested' ? 'a' : 'q'}:${frame.interactionId}`, + frame.type === 'approval/requested' ? 'approval' : questionInteractionStatus(frame.questions), + ) + } else if (frame.type === 'approval/resolved' || frame.type === 'question/resolved') { + const interactions = this.interactions.get(frame.sessionId) + interactions?.delete(frame.interactionId) + if (interactions?.size === 0) this.interactions.delete(frame.sessionId) + this.resolvePending( + frame.sessionId, + `${frame.type === 'approval/resolved' ? 'a' : 'q'}:${frame.interactionId}`, ) - } else if (frame.type === 'question/resolved') { - this.resolvePending(frame.sessionId, `q:${frame.questionRpcId}`) } - const session = this.sessions.get(frame.sessionId) - if (session === undefined) { - // Answerable requests never hit history: retain each live identity until - // instantiation, compacting replay duplicates and resolutions so list - // status cannot outlive the PendingWait the user would need to answer. - // Queue is a latest-value snapshot; everything else drops because open - // backfills it from history. - switch (frame.type) { - case 'approval/requested': - case 'question/requested': - case 'session/queue': { - const buffer = this.pendingBuffers.get(frame.sessionId) ?? [] - const key = frame.type === 'approval/requested' - ? `a:${frame.approvalId}` - : frame.type === 'question/requested' ? `q:${envelope.rpcId}` : 'queue' - const prior = buffer.findIndex(item => bufferedRequestKey(item) === key) - if (prior === -1) buffer.push(envelope) - else buffer[prior] = envelope - this.pendingBuffers.set(frame.sessionId, buffer) - return - } - case 'approval/resolved': - case 'question/resolved': { - const buffer = this.pendingBuffers.get(frame.sessionId) - if (buffer === undefined) return - const key = frame.type === 'approval/resolved' - ? `a:${frame.approvalId}` - : `q:${frame.questionRpcId}` - const prior = buffer.findIndex(item => bufferedRequestKey(item) === key) - if (prior !== -1) buffer.splice(prior, 1) - if (buffer.length === 0) this.pendingBuffers.delete(frame.sessionId) - return - } - default: - return + this.sessions.get(frame.sessionId)?.handleControlFrame(frame) + } + + private replaceControlBaseline(baseline: SessionControlBaseline): void { + this.queues.clear() + for (const [sessionId, items] of Object.entries(baseline.queues)) { + this.queues.set(sessionId as SessionId, items) + } + + this.jobsBySession.clear() + for (const [sessionId, jobs] of Object.entries(baseline.jobs)) { + if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs) + } + + this.interactions.clear() + this.pendingInteractions.clear() + for (const interaction of [...baseline.approvals, ...baseline.questions]) { + let interactions = this.interactions.get(interaction.sessionId) + if (interactions === undefined) { + interactions = new Map() + this.interactions.set(interaction.sessionId, interactions) } + interactions.set(interaction.interactionId, interaction) + let statuses = this.pendingInteractions.get(interaction.sessionId) + if (statuses === undefined) { + statuses = new Map() + this.pendingInteractions.set(interaction.sessionId, statuses) + } + const approval = 'approvalId' in interaction + statuses.set( + `${approval ? 'a' : 'q'}:${interaction.interactionId}`, + approval ? 'approval' : questionInteractionStatus(interaction.questions), + ) } - session.handleMuxEnvelope(envelope.rpcId, frame) + + for (const [sessionId, block] of Object.entries(baseline.projections)) { + const store = this.projectionStore(sessionId as SessionId) + store.truncate(block.asOfSeq) + store.seed(block) + } + for (const [sessionId, session] of this.sessions) { + session.replaceControl( + this.queues.get(sessionId) ?? [], + [...(this.interactions.get(sessionId)?.values() ?? [])], + ) + } + this.notifier.markDirty() } /** - * Host frame entry: list upkeep + per-instance running/removed/agent-error relay. - * @param envelope - the frame with its wire rpcId. + * Apply one Session-list addition forwarded through `ctx.remote.$on`. + * @param summary - current Host summary for the added Session. */ - handleHostEnvelope(envelope: RpcRequest): void { - const frame = envelope.payload - switch (frame.type) { - case 'host/session-added': { - this.mergeSummary({ - sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank, - ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), - ...(frame.origin !== undefined ? { origin: frame.origin } : {}), - ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), - ...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}), - }) - this.sessions.get(frame.sessionId)?.handleBlank(frame.blank) - if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) { - this.markCatalogParentExpandable(frame.parentSessionId) - } - if (frame.parentSessionId !== undefined - && (this.selected === frame.parentSessionId || this.openCatalogs.has(frame.parentSessionId))) { - this.scheduleCatalogRefresh(frame.parentSessionId) - } - return + handleSessionAdded(summary: SessionSummary): void { + this.mergeSummary(summary) + this.sessions.get(summary.sessionId)?.handleBlank(summary.blank) + const projections = summary.projections + if (projections !== undefined) { + const store = this.projectionStore(summary.sessionId) + for (const [key, value] of Object.entries(projections.values)) { + store.apply(key, value, projections.asOfSeq) } - case 'host/session-removed': { - const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId) - const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId) - this.recordMutation(durableSubagent - ? { kind: 'status', sessionId: frame.sessionId, running: false } - : { kind: 'remove', sessionId: frame.sessionId }) - this.updateCatalogActivity(frame.sessionId, false) - if (durableSubagent) { - // An Activation detaching is not durable child deletion: - // keep its lineage and conversation while returning it to idle. - this.sessions.get(frame.sessionId)?.handleRunning(false) - } else { - this.sessions.get(frame.sessionId)?.handleRemoved() - } - this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation - this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone - // Owner disposal already dropped these registry-side, but that lands on - // the mux stream while this frame rides the host stream, so the two have - // no relative order. Clearing here makes a detached Activation's rows - // disappear whichever arrives first. - this.jobsBySession.delete(frame.sessionId) - if (!durableSubagent) this.projectionStores.delete(frame.sessionId) - // A pull already in flight was requested before this removal and can - // carry the pre-removal parentAvailable:true, which would resurrect - // the writable editor this invalidation just closed. Replay false over - // that response and queue one trailing refresh so the post-removal - // host truth converges. - const inflightCatalog = this.catalogInflight.get(frame.sessionId) - if (inflightCatalog !== undefined) { - inflightCatalog.parentAvailableOverride = false - this.catalogStale.add(frame.sessionId) - } - // The removed session can no longer be the delivery owner of its - // catalog: invalidate availability immediately. Removal schedules no - // catalog refresh, and without this an addressed child keeps a - // writable editor against a dead continuation owner until an - // unrelated refresh (or forever, for a closed menu). - const ownedCatalog = this.catalogs.get(frame.sessionId) - if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) { - this.catalogs.set(frame.sessionId, { ...ownedCatalog, parentAvailable: false }) - } - for (const [childId, address] of this.addresses) { - if (address.parentSessionId !== frame.sessionId) continue - this.sessions.get(childId)?.handleSubagentParentAvailable(false) - } - return - } - case 'host/session-status': { - this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running }) - this.sessions.get(frame.sessionId)?.handleRunning(frame.running) - this.updateCatalogActivity(frame.sessionId, frame.running) - return - } - case 'host/agent-error': { - this.sessions.get(frame.sessionId)?.handleAgentError(frame.message) - return // not reflected in the list - } - default: - return // stream/error ignored; unknown frames ignored (documented default) + } + if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) { + this.markCatalogParentExpandable(summary.parentSessionId) + } + if (summary.parentSessionId !== undefined + && (this.selected === summary.parentSessionId || this.openCatalogs.has(summary.parentSessionId))) { + this.scheduleCatalogRefresh(summary.parentSessionId) } } /** - * The moment a connection generation dies (before any next-generation frame - * can arrive — onConnected waits for the readiness handshake while replayed - * frames flow from stream open, so clearing there would race the replay): - * drop generation-scoped live state. Interactions resolved while disconnected - * send no frame, so stale statuses and buffered answerable frames must not - * survive into the next generation — mux-open replay re-adds every still-pending - * request with its live rpcId. - */ - handleDisconnected(): void { - if (this.pendingInteractions.size > 0) { - this.pendingInteractions.clear() - this.notifier.markDirty() + * Apply one Session removal forwarded through `ctx.remote.$on`. + * @param sessionId - removed Session identity. + */ + handleSessionRemoved(sessionId: SessionId): void { + const summary = this.summaries.find(candidate => candidate.sessionId === sessionId) + const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(sessionId) + this.recordMutation(durableSubagent + ? { kind: 'status', sessionId, running: false } + : { kind: 'remove', sessionId }) + this.updateCatalogActivity(sessionId, false) + if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false) + else this.sessions.get(sessionId)?.handleRemoved() + this.queues.delete(sessionId) + this.interactions.delete(sessionId) + this.pendingInteractions.delete(sessionId) + this.jobsBySession.delete(sessionId) + if (!durableSubagent) this.projectionStores.delete(sessionId) + const inflightCatalog = this.catalogInflight.get(sessionId) + if (inflightCatalog !== undefined) { + inflightCatalog.parentAvailableOverride = false + this.catalogStale.add(sessionId) } - for (const [sessionId, buffer] of [...this.pendingBuffers]) { - const kept = buffer.filter(item => - item.payload.type !== 'approval/requested' && item.payload.type !== 'question/requested') - if (kept.length === buffer.length) continue - if (kept.length === 0) this.pendingBuffers.delete(sessionId) - else this.pendingBuffers.set(sessionId, kept) + const ownedCatalog = this.catalogs.get(sessionId) + if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) { + this.catalogs.set(sessionId, { ...ownedCatalog, parentAvailable: false }) + } + for (const [childId, address] of this.addresses) { + if (address.parentSessionId === sessionId) { + this.sessions.get(childId)?.handleSubagentParentAvailable(false) + } } } - /** After each connection generation: refresh the session baseline and rebuild opened windows. */ + /** + * Apply one live Agent running-state change. + * @param sessionId - Session whose Agent state changed. + * @param running - current Agent running state. + */ + handleSessionStatus(sessionId: SessionId, running: boolean): void { + this.recordMutation({ kind: 'status', sessionId, running }) + this.sessions.get(sessionId)?.handleRunning(running) + this.updateCatalogActivity(sessionId, running) + } + + /** + * Advance Session-list activity from one user-authored durable message. + * @param sessionId - Session whose activity changed. + * @param updatedAt - durable message timestamp. + */ + handleSessionActivity(sessionId: SessionId, updatedAt: number): void { + this.recordMutation({ kind: 'activity', sessionId, updatedAt }) + } + + /** + * Surface one live Agent failure on an already-materialized Session. + * @param sessionId - Session whose Agent failed. + * @param message - caller-visible failure description. + */ + handleSessionError(sessionId: SessionId, message: string): void { + this.sessions.get(sessionId)?.handleAgentError(message) + } + + /** + * Repair one re-established Host-event generation with queryable baselines. + * Opened Session follow streams resume independently through API Gateway. + */ handleConnected(): void { void this.refreshList() const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected) if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId) if (this.selected !== undefined) void this.refreshSubagents(this.selected) for (const parentSessionId of this.openCatalogs) void this.refreshSubagents(parentSessionId) - for (const session of this.sessions.values()) void session.resync() } /** Debounce membership refetches while one parent catalog is selected or open. */ @@ -1125,7 +1091,13 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi } /** Temporary source-plane bridge while the Host contract and client project build independently. */ -function workspaceAttachSessionId(error: RpcError): SessionId | undefined { - const candidate = error as unknown as { code: string; details: { sessionId?: SessionId } } - return candidate.code === 'workspace-attach-failed' ? candidate.details.sessionId : undefined +function workspaceAttachSessionId(error: ClientFailure): SessionId | undefined { + return error.code === 'workspace-attach-failed' ? error.details.sessionId : undefined +} + +/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */ +function toSessionResult( + result: import('@deepseek-ai/dsh-typert-protocol').RemoteResult, +): ClientResult { + return result.ok ? result : { ok: false, error: result.error as SessionError } } diff --git a/packages/client/runtime/src/client/sessions/pending.ts b/packages/client/runtime/src/client/sessions/pending.ts index b8b3492a04..f883ce87e4 100644 --- a/packages/client/runtime/src/client/sessions/pending.ts +++ b/packages/client/runtime/src/client/sessions/pending.ts @@ -1,15 +1,20 @@ -// PendingWait: the carrier-protocol half of a pending host interaction. The runtime owns only -// envelope knowledge (rpcId backfill into a client-response); domain result encoding belongs to -// the interaction's consumer package. +// PendingWait: the render-facing half of one Session Controller interaction. +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { - ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId, -} from '@deepseek-ai/dsh-api-remotes/client' + SessionApprovalRequest, + SessionInteractionId, + SessionInteractionResult, + SessionQuestionRequest, + SessionRespondReceipt, + SessionRespondRequest, +} from '@deepseek-ai/dsh-api-session-controller/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' /** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */ export interface PendingPayloads { - approval: Omit, 'type' | 'sessionId'> - question: Omit, 'type' | 'sessionId'> + approval: Omit + question: Omit } /** Pending-interaction discriminant (the keys of PendingPayloads). */ @@ -26,53 +31,60 @@ const KEY_PREFIX: Record = { approval: 'a', question: 'q' } /** * One pending host-owned interaction wait: an immutable render face - * (kind/key/sessionId/payload) plus the response carrier. respond() backfills - * the requested frame's rpcId into a client-response envelope — no consumer - * ever sees the raw rpcId. Settlement is expressed only by pending-list + * (kind/key/sessionId/payload) plus the response carrier. respond() addresses + * the Host's opaque interaction identity. Settlement is expressed only by pending-list * membership (the settled flag is a fail-loud guard, not a render input). */ export class PendingWait { /** Interaction kind (union discriminant). */ readonly kind: K - /** Opaque render identity, `:` — stable across baseline replay, usable as a React key. */ + /** Opaque render identity, stable across baseline replay and usable as a React key. */ readonly key: string /** Owning session. */ readonly sessionId: SessionId /** The requested frame's domain fields, verbatim. */ readonly payload: PendingPayloads[K] #settled = false - readonly #rpcId: RpcId - readonly #respond: (message: ClientResponse) => Promise + readonly #interactionId: SessionInteractionId + readonly #respond: (request: SessionRespondRequest) => Promise> /** * Minted by Session on a requested frame (public construction is the test-fixture path). * @param kind - interaction kind. - * @param rpcId - the requested frame's stable envelope id (kept private; respond echoes it). + * @param interactionId - the Host-minted stable interaction identity. * @param sessionId - owning session. * @param payload - the requested frame's domain fields. - * @param respond - the client-response carrier (api.respond). + * @param respond - Session Controller response method. */ constructor( - kind: K, rpcId: RpcId, sessionId: SessionId, payload: PendingPayloads[K], - respond: (message: ClientResponse) => Promise, + kind: K, interactionId: SessionInteractionId, sessionId: SessionId, payload: PendingPayloads[K], + respond: (request: SessionRespondRequest) => Promise>, ) { this.kind = kind - this.key = `${KEY_PREFIX[kind]}:${rpcId}` + this.key = `${KEY_PREFIX[kind]}:${interactionId}` this.sessionId = sessionId this.payload = payload - this.#rpcId = rpcId + this.#interactionId = interactionId this.#respond = respond } /** - * Send a result for this wait: wraps it into the client-response envelope - * with the rpcId backfilled. Throws synchronously once settled. + * Send a result for this wait. Throws synchronously once settled and rejects + * when the generated Remote call itself fails. * @param result - the result shell (ok value / error envelope), domain-encoded by the caller. * @returns the carrier receipt. */ - respond(result: ClientResponse['result']): Promise { + respond(result: SessionInteractionResult): Promise { if (this.#settled) throw new Error(`pending wait ${this.key} is already settled`) - return this.#respond({ type: 'client-response', rpcId: this.#rpcId, result }) + return this.send(result) + } + + private async send(result: SessionInteractionResult): Promise { + const response = await this.#respond({ interactionId: this.#interactionId, result }) + if (!response.ok) { + throw new Error(`session interaction response failed: ${response.error.code}: ${response.error.message}`) + } + return response.value } /** Session-only settlement mark (the authoritative resolved frame arrived); respond() throws afterwards. */ diff --git a/packages/client/runtime/src/client/sessions/projection-store.ts b/packages/client/runtime/src/client/sessions/projection-store.ts index ba3588c46d..8996d73c78 100644 --- a/packages/client/runtime/src/client/sessions/projection-store.ts +++ b/packages/client/runtime/src/client/sessions/projection-store.ts @@ -2,8 +2,8 @@ * Generic per-session projection value store (push model; see the * session-projection subsystem page, docs/subsystems/session-projection.md): * the host is the only computation site; the client holds finished - * whole values per key — `key → { value, seq }` — seeded by the history tail - * page's projections block and updated by `session/projection` push frames, + * whole values per key — `key → { value, seq }` — seeded by a Session page's + * projections block and updated by Session Controller `projection` frames, * under the single rule **higher seq wins**. No client-side domain folding * exists: a domain ships projection support with zero client code. Per-key * bare observable faces feed `useProjection` (ui-renderer binds them). @@ -40,8 +40,8 @@ export type UseProjection = { } /** - * Tail-page projections baseline — structurally identical to the wire's - * `SessionProjectionsBlock` (apiproxy api layer), restated here so the + * Tail-page projections baseline — structurally identical to Session + * Controller's `SessionProjectionsBlock`, restated here so the * React-free store depends only on the type table, not the wire package's * response vocabulary. */ @@ -49,7 +49,7 @@ export interface ProjectionsBaseline { /** The consistent-cut seq (equals the window tail seq by construction). */ asOfSeq: number /** Whole current values by key; a registered key absent here means the capability is absent. */ - values: Partial + values: Readonly> } /** One key's row: the latest finished value and the seq it is consistent with. */ @@ -126,7 +126,7 @@ export class ProjectionValueStore { } /** - * Apply one finished value (the `session/projection` push-frame path). + * Apply one finished value from the Session control stream. * @param key - projection key. * @param value - whole value computed by the host unit. * @param seq - the unit's watermark at emission. @@ -160,13 +160,11 @@ export class ProjectionValueStore { } /** - * Drop rows past a mux-generation baseline (`session/subscribed.lastSeq`): - * a row claiming knowledge beyond the host's own durable baseline rode - * state a restart lost — under last-wins it would wrongly outrank the - * host's recomputed (lower-seq) values forever. Durable replay and the next - * baseline re-seed whatever truly survived (the title-snapshot precedent, - * generalized). - * @param lastSeq - the subscribed frame's durable baseline seq. + * Drop rows beyond a replacement control baseline. Such rows describe + * process state the Host lost before persisting it and would otherwise + * outrank recomputed lower-seq values forever. The caller seeds the new + * baseline immediately afterward. + * @param lastSeq - highest durable sequence reflected by the baseline. */ truncate(lastSeq: number): void { for (const [key, row] of this.rows) { diff --git a/packages/client/runtime/src/client/sessions/queue-mirror.ts b/packages/client/runtime/src/client/sessions/queue-mirror.ts index 1eb4e6fdbe..b364f5b104 100644 --- a/packages/client/runtime/src/client/sessions/queue-mirror.ts +++ b/packages/client/runtime/src/client/sessions/queue-mirror.ts @@ -1,5 +1,5 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { MuxFrame } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionQueuedItem } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { QueuedMessage } from './conversation.ts' @@ -18,7 +18,7 @@ function textOf(content: readonly ContentBlock[]): string | null { return content.map(block => block.text).join('') } -type QueueItems = Extract['items'] +type QueueItems = readonly SessionQueuedItem[] /** Authoritative transient queue projection and durable steering handoff. */ export class SessionQueueMirror { @@ -32,29 +32,22 @@ export class SessionQueueMirror { return this.current } - /** - * Drop the stale generation before its replacement queue baseline arrives. - * @returns whether any projected queue row was removed. - */ - reset(): boolean { - if (this.current.length === 0) return false - this.current = [] - return true - } - /** * Replace from one authoritative stream queue frame. * @param items - complete host queue snapshot. */ replace(items: QueueItems): void { - this.current = items.map(item => ({ - id: item.id, - messageId: item.message.id, - placement: item.placement, - content: item.message.content, - preview: previewOf(item.message.content), - text: textOf(item.message.content), - })) + this.current = items.map((item) => { + const content = item.message.content as unknown as readonly ContentBlock[] + return { + id: item.id, + messageId: item.message.id, + placement: item.placement, + content, + preview: previewOf(content), + text: textOf(content), + } + }) } /** diff --git a/packages/client/runtime/src/client/sessions/remotes.ts b/packages/client/runtime/src/client/sessions/remotes.ts index 4fa503345b..6479aee4be 100644 --- a/packages/client/runtime/src/client/sessions/remotes.ts +++ b/packages/client/runtime/src/client/sessions/remotes.ts @@ -8,5 +8,5 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' -/** The generated Remote namespaces a Session and its manager call. */ -export type SessionRemotes = Pick +/** The generated Remote namespaces and Gateway stream factory a Session cluster uses. */ +export type SessionRemotes = Pick diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index f782d8cc71..d654d65085 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -16,11 +16,9 @@ */ import type { Context, Fiber } from '@deepseek-ai/cordis' import type { - IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, JobView, WorkspaceId, + ClientFailure, ClientResult, IApiClient, SessionId, SubagentAddress, JobView, WorkspaceId, } from '@deepseek-ai/dsh-api-remotes/client' -// Value import from the inline-safe wire layer (not the connection plugin): -// plugin-to-plugin value imports are a bundle purity error. -import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' +import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-api-session-controller/client' import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, } from '@deepseek-ai/dsh-client-ui-slots' @@ -88,9 +86,9 @@ export interface SessionListState { /** Direct durable catalogs keyed by their selected parent address. */ subagentsByParent: Readonly> /** - * Background jobs each session can see, mirrored last-wins from - * `session/jobs`. A missing key is an empty set — the Host sends no baseline - * for a session without tasks — so consumers read absence, never a sentinel. + * Background jobs each session can see, mirrored last-wins from Session + * Controller's control baseline and `jobs` frames. A missing key is an empty + * set, so consumers read absence rather than a sentinel. */ jobsBySession: Readonly> /** Current session's catalog-derived address, absent on ordinary navigation. */ @@ -112,7 +110,7 @@ export class SessionCreateError extends Error { * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. */ constructor( - readonly rpcError: RpcError, + readonly rpcError: ClientFailure, readonly requestedSessionId: SessionId | undefined, ) { super(`session create failed: ${rpcError.code}: ${rpcError.message}`) @@ -128,7 +126,7 @@ export class SessionForkError extends Error { * @param sourceSessionId - the session the fork was cut from. */ constructor( - readonly rpcError: RpcError, + readonly rpcError: ClientFailure, readonly sourceSessionId: SessionId, ) { super(`session fork failed: ${rpcError.code}: ${rpcError.message}`) @@ -441,24 +439,56 @@ export class SessionRuntime implements ISessions { search( query: string, signal: AbortSignal, - ): Promise> { + ): Promise> { return this.manager.search(query, signal) } /** - * Route a mux stream envelope into the Session object layer. - * @param envelope - validated mux stream envelope. + * Apply one Session Controller live-control frame. + * @param frame - baseline or live control replacement. */ - handleMuxEnvelope(envelope: Parameters[0]): void { - this.manager.handleMuxEnvelope(envelope) + handleControlFrame(frame: Parameters[0]): void { + this.manager.handleControlFrame(frame) } /** - * Route a Host stream envelope into the Session object layer. - * @param envelope - validated Host stream envelope. + * Apply one remotely forwarded Session-list addition. + * @param summary - current Host summary for the added Session. */ - handleHostEnvelope(envelope: Parameters[0]): void { - this.manager.handleHostEnvelope(envelope) + handleSessionAdded(summary: Parameters[0]): void { + this.manager.handleSessionAdded(summary) + } + + /** + * Apply one remotely forwarded Session removal. + * @param sessionId - removed Session identity. + */ + handleSessionRemoved(sessionId: Parameters[0]): void { + this.manager.handleSessionRemoved(sessionId) + } + + /** + * Apply one remotely forwarded running-state change. + * @param args - Session identity and current Agent running state. + */ + handleSessionStatus(...args: Parameters): void { + this.manager.handleSessionStatus(...args) + } + + /** + * Apply one remotely forwarded list-activity change. + * @param args - Session identity and durable activity timestamp. + */ + handleSessionActivity(...args: Parameters): void { + this.manager.handleSessionActivity(...args) + } + + /** + * Apply one remotely forwarded Agent failure. + * @param args - Session identity and caller-visible failure description. + */ + handleSessionError(...args: Parameters): void { + this.manager.handleSessionError(...args) } /** Rebuild the Session baseline and every opened window after connection. */ @@ -466,11 +496,6 @@ export class SessionRuntime implements ISessions { this.manager.handleConnected() } - /** Drop generation-scoped live interaction state the moment a connection generation dies. */ - handleDisconnected(): void { - this.manager.handleDisconnected() - } - /** * Create a session on the host. Resolution guarantee: by the time the * promise resolves, the created session is in the list store and diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 02939cf28f..ad1753099a 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,12 +1,31 @@ -// Sessions remain resident after creation so they continue consuming mux frames off-screen. +// Sessions remain resident after creation so their open Remote sources keep running off-screen. import type { Context } from '@deepseek-ai/cordis' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { - HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError, - RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView, + ClientFailure, ClientResult, IApiClient, MessageId, PromptContentPart, QueueAction, + SessionId, SubagentAddress, } from '@deepseek-ai/dsh-api-remotes/client' +import { + SessionEventStream, + sessionStreamFailure, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { + SessionEventChange, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { + SessionAddress, + SessionApprovalRequest, + SessionControlFrame, + SessionEventEntry, + SessionQuestionRequest, + SessionQueuedItem, + SessionRequestId, + SessionError, + SessionToolView, +} from '@deepseek-ai/dsh-api-session-controller/types' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -64,17 +83,16 @@ export interface SessionOptions { */ export class Session implements SessionFace { // ---- Window and derived state (all private; the snapshot is the only read API) ---- - private events: SessionEvent[] = [] - /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view). - * Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */ - private views: (ToolEventView | undefined)[] = [] + private eventWindow: SessionEvent[] = [] + /** Wire views aligned with `eventWindow` by index (envelope annotations; undefined = no view). + * Kept parallel so `eventWindow` remains the raw log slice (model-visible ⟺ logged). */ + private views: (SessionToolView | undefined)[] = [] private baseSeq = 0 private hasMore = false private openState: OpenState = 'cold' - private openError: RpcError | null = null + private openError: ClientFailure | null = null private openPromise: Promise | null = null - /** Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt - * a pre-disconnect open whose history request is already doomed. Stale doOpen + /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale * passes drop all writes once the generation moves on. */ private openGeneration = 0 private loadingOlder = false @@ -101,18 +119,14 @@ export class Session implements SessionFace { private removed = false private promptError: PromptError | null = null private lastAgentError: string | null = null - /** Live events buffered during open/resync and stitched by sequence once history lands. */ - private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = [] - /** Gap repair in flight; live events detour to the buffer until the tail page lands. */ - private stitching = false - /** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */ - private subscribedLastSeq: number | null = null + /** Owns the addressed page/follow lifecycle while this Session is open. */ + private events: SessionEventStream | undefined /** * Per-session projection value store (push model; see the session-projection * subsystem page, docs/subsystems/session-projection.md): finished whole - * values computed on the host, seeded by the tail page's - * projections block and updated by `session/projection` frames under the + * values computed on the Host, seeded by the tail page's + * projections block and updated by Session Controller control frames under the * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)` * (the useProjection resolution face); the conversation snapshot never * carries projection values, and no client-side domain folding exists. @@ -191,7 +205,7 @@ export class Session implements SessionFace { content: PromptContentPart[], mode: 'queue' | 'steer', signal?: AbortSignal, - ): Promise> { + ): Promise> { this.promptError = null this.lastAgentError = null // Synchronous, before the first await: the blank → engaging edge must be @@ -200,15 +214,17 @@ export class Session implements SessionFace { this.promptAttempted = true if (this.blankBit) this.firstPromptPendingTurn = true this.notifier.markDirty() - let result: RpcResult<{ accepted: true }> + let result: ClientResult<{ accepted: true }> try { if (this.address === undefined) { - result = (await this.api.sessions.prompt({ + const clientTimeZone = resolvedClientTimeZone() + result = toSessionResult(await this.remote.session.prompt({ + requestId: randomUUID() as SessionRequestId, sessionId: this.sessionId, mode, content, - clientTimeZone: resolvedClientTimeZone(), - }, signal)).result + clientTimeZone, + }, signal)) } else if (this.address.mode === 'one-shot') { result = { ok: false, @@ -270,13 +286,13 @@ export class Session implements SessionFace { */ async readAttachment( attachmentId: AttachmentIdType, - ): Promise> { + ): Promise> { try { - const result = (await this.api.sessions.attachment({ + const result = await this.remote.session.attachment({ sessionId: this.sessionId, attachmentId, - })).result - if (!result.ok) return result + }) + if (!result.ok) return toSessionResult(result) const binary = atob(result.value.data) const data = Uint8Array.from(binary, char => char.charCodeAt(0)) return { ok: true, value: { attachment: result.value.attachment, data } } @@ -286,9 +302,9 @@ export class Session implements SessionFace { } /** Apply one operation to a still-pending queue occurrence. */ - async updateQueue(itemId: MessageId, action: QueueAction): Promise> { + async updateQueue(itemId: MessageId, action: QueueAction): Promise> { try { - return (await this.api.sessions.updateQueue({ sessionId: this.sessionId, itemId, action })).result + return toSessionResult(await this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action })) } catch (error) { return transportError(error) } @@ -303,10 +319,10 @@ export class Session implements SessionFace { * defensive). * @returns the cancel result. */ - async cancel(): Promise> { + async cancel(): Promise> { const address = this.address if (address !== undefined && address.mode === 'one-shot') { - const result: RpcResult<{ accepted: true }> = { + const result: ClientResult<{ accepted: true }> = { ok: false, error: { code: 'subagent-delivery-unavailable', @@ -318,11 +334,11 @@ export class Session implements SessionFace { this.notifier.markDirty() return result } - let result: RpcResult<{ accepted: true }> + let result: ClientResult<{ accepted: true }> try { result = address !== undefined ? (await this.api.subagents.interrupt(address)).result - : (await this.api.sessions.cancel({ sessionId: this.sessionId })).result + : toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId })) } catch (error) { result = transportError(error) } @@ -338,13 +354,13 @@ export class Session implements SessionFace { * projection cell from the response's `{title, seq}` under the store's * higher-seq-wins rule (the push frame arriving later is a no-op replay), * so the list row and any useProjection('title') reader update without - * waiting for the mux frame. + * waiting for the control-stream projection update. * @param title - raw title text (the host normalizes acceptance). * @returns the rename result (normalized accepted title + title event seq). */ - async rename(title: string): Promise> { + async rename(title: string): Promise> { try { - const { result } = await this.api.sessions.rename({ sessionId: this.sessionId, title }) + const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title })) if (result.ok) this.projections.apply('title', result.value.title, result.value.seq) return result } catch (error) { @@ -380,63 +396,37 @@ export class Session implements SessionFace { /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */ async loadOlder(): Promise { if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return + const events = this.events + if (events === undefined) return this.loadingOlder = true this.notifier.markDirty() try { - const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES }) - if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded) - const older = result.value.events - if (older.length === 0) { - this.hasMore = result.value.hasMore - this.conversation.prepend([], this.hasMore) - return - } - const tail = older[older.length - 1] - if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) { - // Continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream. - console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`) - this.hasMore = false - this.conversation.prepend([], false) - return - } - this.events = [...older.map(e => e.event), ...this.events] - this.views = [...older.map(e => e.view), ...this.views] - /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */ - this.baseSeq = older[0]?.event.seq ?? this.baseSeq - this.hasMore = result.value.hasMore - this.conversation.prepend(older.map(conversationInput), this.hasMore) + await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES }) } catch (error) { - console.error('[web-runtime] loadOlder failed:', error) + if (sessionStreamFailure(error) === undefined) { + console.error('[web-runtime] loadOlder failed:', error) + } } finally { this.loadingOlder = false this.notifier.markDirty() } } - /** Reconnect rebuild (manager calls this on onConnected for instances that were opened): - * reset the window and rerun open; pending waits for the baseline replay. Invalidates any - * in-flight open first — its history request rode the dead connection and must not settle - * the fresh generation into 'error'. */ + /** Rebuild an opened history source after address replacement. + * Invalidates any in-flight open first; queue and pending-interaction state belongs + * to the independently reconnecting control stream and remains untouched. */ async resync(): Promise { - // The queue mirror is NOT cleared here: onConnected (which drives resync) - // races the mux frames — the fresh generation's baseline may have landed - // already, and the host never resends it. The mirror re-baselines on the - // session/subscribed frame instead (same stream as the queue snapshot - // that follows it, so ordering is guaranteed). if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) this.openGeneration++ + const events = this.events + this.events = undefined + await events?.dispose() this.openPromise = null this.openState = 'cold' this.openError = null - this.events = [] + this.eventWindow = [] this.views = [] this.baseSeq = 0 - // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim - // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. - this.pending.clear() - this.pendingRev++ - this.subscribedLastSeq = null - this.liveBuffer = [] this.notifier.markDirty() await this.open() } @@ -464,57 +454,42 @@ export class Session implements SessionFace { // ---- Manager-only entry points (@internal; never called by the UI) ---- /** - * Mux frame arrival (the dispatch switch). - * @param rpcId - the frame envelope id (the respond backfill key for requested frames). - * @param frame - the routed frame. + * Replace every transient control value for this Session from one stream baseline. + * @param queue - complete pending queue for this Session. + * @param interactions - complete pending approval and question set. */ - handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void { + replaceControl( + queue: readonly SessionQueuedItem[], + interactions: readonly (SessionApprovalRequest | SessionQuestionRequest)[], + ): void { + this.queueMirror.replace(queue) + this.pending.clear() + this.pendingRev++ + for (const interaction of interactions) this.requestInteraction(interaction) + this.notifier.markDirty() + } + + /** + * Apply one Session-addressed live control update. + * @param frame - queue or interaction replacement addressed to this Session. + */ + handleControlFrame(frame: Exclude): void { switch (frame.type) { - case 'session/event': { - this.acceptLiveEvent(frame.event, frame.view) - return - } - case 'session/queue': { + case 'queue': this.queueMirror.replace(frame.items) this.notifier.markDirty() return - } - case 'session/subscribed': { - this.subscribedLastSeq = frame.lastSeq - // New mux-generation baseline: the host pushes this session's queue - // snapshot AFTER the subscribed frame on the same stream, so the - // stale mirror clears here — race-free against onConnected/resync - // timing (clearing there could wipe a baseline that already landed). - if (this.queueMirror.reset()) this.notifier.markDirty() - return - } - case 'approval/requested': { - const { type: _type, sessionId: _sid, ...payload } = frame - this.mint(new PendingWait('approval', rpcId, this.sessionId, payload, m => this.api.respond(m))) + case 'approval/requested': + case 'question/requested': + this.requestInteraction(frame) this.notifier.markDirty() return - } - case 'approval/resolved': { - for (const item of this.pending.values()) { - if (item.kind === 'approval' && item.payload.approvalId === frame.approvalId) this.settle(item) - } - this.notifier.markDirty() + case 'approval/resolved': + this.resolveInteraction(`a:${frame.interactionId}`) return - } - case 'question/requested': { - const { type: _type, sessionId: _sid, ...payload } = frame - this.mint(new PendingWait('question', rpcId, this.sessionId, payload, m => this.api.respond(m))) - this.notifier.markDirty() + case 'question/resolved': + this.resolveInteraction(`q:${frame.interactionId}`) return - } - case 'question/resolved': { - const item = this.pending.get(`q:${frame.questionRpcId}`) - if (item !== undefined) this.settle(item) - this.notifier.markDirty() - return - } - default: - return // stream/error never reaches Session (Controller converges it); unknown frames ignored (documented default) } } @@ -562,8 +537,8 @@ export class Session implements SessionFace { } /** - * Blank-bit relay from the authoritative summary source (list baseline and - * the session-added frame). Monotone: once any signal (local first send, + * Blank-bit relay from the authoritative summary source (`session.list` and + * `api-session/added`). Monotone: once any signal (local first send, * running flip, an earlier summary) cleared it, a stale true never * re-blanks. * @param blank - the summary's derived empty-log bit. @@ -575,14 +550,14 @@ export class Session implements SessionFace { this.notifier.markDirty() } - /** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */ + /** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */ handleRemoved(): void { this.removed = true this.notifier.markDirty() } /** - * host/agent-error relay: the only outlet for live failures with no turn position. + * `api-session/error` relay: the outlet for live failures with no turn position. * @param message - the stringified error. */ handleAgentError(message: string): void { @@ -590,8 +565,13 @@ export class Session implements SessionFace { this.notifier.markDirty() } - /** No-op because session instances remain resident. */ - dispose(): void {} + /** Stop the Session's live Remote source. */ + dispose(): void { + this.openGeneration++ + const events = this.events + this.events = undefined + void events?.dispose() + } /** Rebuild the current window after a low-frequency Definition or view registration change. */ rebuildConversationRegistry(): void { @@ -613,66 +593,100 @@ export class Session implements SessionFace { this.pendingRev++ } - /** @param generation - openGeneration at launch; every await re-checks it and a stale pass - * drops all writes (resync superseded this open — its outcome belongs to a dead connection). */ + private requestInteraction(interaction: SessionApprovalRequest | SessionQuestionRequest): void { + if ('approvalId' in interaction) { + const { interactionId, sessionId: _sessionId, ...payload } = interaction + this.mint(new PendingWait( + 'approval', interactionId, this.sessionId, payload, + request => this.remote.session.respond(request), + )) + return + } + const { interactionId, sessionId: _sessionId, ...payload } = interaction + this.mint(new PendingWait( + 'question', interactionId, this.sessionId, payload, + request => this.remote.session.respond(request), + )) + } + + private resolveInteraction(key: string): void { + const interaction = this.pending.get(key) + if (interaction === undefined) return + this.settle(interaction) + this.notifier.markDirty() + } + + /** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */ private async doOpen(generation: number): Promise { this.openState = 'loading' this.openError = null this.notifier.markDirty() + const events = new SessionEventStream(this.remote, this.sessionAddress(), { + publish: (change) => { + if (generation !== this.openGeneration || this.events !== events) return + this.acceptEventChange(change) + }, + failed: (error) => { + this.failEventStream(events, generation, error) + }, + }) + this.events = events try { - let { result } = await this.history({ maxMessages: PAGE_MESSAGES }) - if (generation !== this.openGeneration) return - if (!result.ok) { - this.openState = 'error' - this.openError = result.error - return - } - this.installWindow(result.value.events, result.value.hasMore, result.value.projections) - // Gap detection: baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more. - const tailSeq = this.windowTailSeq() - if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) { - result = (await this.history({ maxMessages: PAGE_MESSAGES })).result - if (generation !== this.openGeneration) return - if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections) - } + await events.open({ maxMessages: PAGE_MESSAGES }) + if (generation !== this.openGeneration || this.events !== events) return this.openState = 'open' } catch (error) { - if (generation !== this.openGeneration) return + if (generation !== this.openGeneration || this.events !== events) return + this.events = undefined this.openState = 'error' - const folded = transportError(error) - /* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */ - this.openError = folded.ok ? null : folded.error + this.openError = openFailure(error) } finally { if (generation === this.openGeneration) this.notifier.markDirty() } } - /** Install the history window + stitch the liveBuffer (seq is the sole dedup key). - * Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here - * (doOpen flips it after install), so recursing would push every buffered event straight - * back into liveBuffer where nothing ever drains it — a silent drop loop. - * A carried projections block seeds the value store (higher seq wins, so a stale - * baseline cannot overwrite a newer push frame); the window events themselves are - * never folded — the host is the only computation site. */ - private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { - this.events = entries.map(e => e.event) - this.views = entries.map(e => e.view) - this.baseSeq = this.events[0]?.seq ?? 0 + /** Apply one contiguous journal update already reconciled by the Remote stream. */ + private acceptEventChange(change: SessionEventChange): void { + switch (change.type) { + case 'replace': + this.installWindow(change.entries, change.hasMore, change.page.projections) + return + case 'prepend': + this.prependWindow(change.entries, change.hasMore) + return + case 'append': { + const entry = conversationInput(change.entry) + this.scheduleConversation(this.appendLive(entry.event, entry.view)) + } + } + } + + /** Replace the complete contiguous window and apply page-owned projection metadata. */ + private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { + const normalized = entries.map(conversationInput) + this.eventWindow = normalized.map(entry => entry.event) + this.views = normalized.map(entry => entry.view) + this.baseSeq = this.eventWindow[0]?.seq ?? 0 this.hasMore = hasMore - if (this.events.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false - this.conversation.replaceWindow(entries.map(conversationInput), hasMore) + if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false + this.conversation.replaceWindow(normalized, hasMore) if (projections !== undefined) this.projections.seed(projections) - const buffered = this.liveBuffer - this.liveBuffer = [] - for (const item of buffered) this.appendLive(item.event, item.view) this.notifier.markDirty() } - /** Seq-guarded append shared by stitching and the open-state live path. */ - private appendLive(event: SessionEvent, view?: ToolEventView): ConversationPublication { - const tailSeq = this.windowTailSeq() - if (tailSeq !== null && event.seq <= tailSeq) return 'none' // replay overlap, drop - this.events.push(event) + /** Prepend one stream-validated history page. */ + private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void { + const normalized = entries.map(conversationInput) + this.eventWindow = [...normalized.map(entry => entry.event), ...this.eventWindow] + this.views = [...normalized.map(entry => entry.view), ...this.views] + this.baseSeq = this.eventWindow[0]?.seq ?? 0 + this.hasMore = hasMore + this.conversation.prepend(normalized, hasMore) + } + + /** Append one stream-validated live event. */ + private appendLive(event: SessionEvent, view?: SessionToolView): ConversationPublication { + this.eventWindow.push(event) this.views.push(view) if (event.type === 'turn/start') this.firstPromptPendingTurn = false const queueChanged = this.queueMirror.acceptDurable(event) @@ -680,56 +694,22 @@ export class Session implements SessionFace { return queueChanged ? 'immediate' : publication } - /** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop; - * a seq gap -> buffer + tail-page repull instead of appending a hole (a gap is an - * expected reconnect-window artifact, repaired by refetch). The window stays one contiguous - * raw range, which lets Conversation Definitions correlate every recorded event between its - * ends and lets a compaction checkpoint resolve its cited summary event. */ - private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void { - if (this.openState === 'loading' || this.stitching) { - this.liveBuffer.push({ event, view }) - return - } - if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open) - const tailSeq = this.windowTailSeq() - if (tailSeq !== null && event.seq > tailSeq + 1) { - this.liveBuffer.push({ event, view }) - void this.repairGap() - return - } - this.scheduleConversation(this.appendLive(event, view)) - } - /** Route assembler cadence into the Session's existing microtask/RAF notifier. */ private scheduleConversation(publication: ConversationPublication): void { if (publication === 'immediate') this.notifier.markDirty() else if (publication === 'animation-frame') this.notifier.markFrameDirty() } - /** Resync-lite: repull the tail page and stitch the liveBuffer through the shared - * installWindow path. No openState transition — the UI keeps the current window (no loading - * flash); events arriving meanwhile detour to liveBuffer via the stitching flag. */ - private async repairGap(): Promise { - /* v8 ignore next -- re-entry guard: acceptLiveEvent already detours to liveBuffer while stitching, so no second call reaches here. */ - if (this.stitching) return - this.stitching = true - const generation = this.openGeneration - try { - const { result } = await this.history({ maxMessages: PAGE_MESSAGES }) - // Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself. - if (result.ok && generation === this.openGeneration && this.openState === 'open') { - this.installWindow(result.value.events, result.value.hasMore, result.value.projections) - } - } catch (error) { - console.error('[web-runtime] gap repair failed:', error) - } finally { - this.stitching = false - } - } - - private windowTailSeq(): number | null { - const tail = this.events[this.events.length - 1] - return tail === undefined ? null : tail.seq + /** Publish a terminal background failure only while this stream still owns the Session. */ + private failEventStream(events: SessionEventStream, generation: number, error: unknown): void { + if (generation !== this.openGeneration || this.events !== events) return + this.openGeneration++ + this.events = undefined + this.openPromise = null + this.openState = 'error' + this.openError = openFailure(error) + void events.dispose() + this.notifier.markDirty() } private buildSnapshot(): ConversationSnapshot { @@ -771,21 +751,34 @@ export class Session implements SessionFace { } } - /** Select ordinary or addressed history transport from the stored browser fact. */ - private history(payload: { beforeSeq?: number; maxMessages?: number }): Promise> { + private sessionAddress(): SessionAddress { return this.address === undefined - ? this.api.sessions.history({ sessionId: this.sessionId, ...payload }) - : this.api.subagents.history({ ...this.address, ...payload }) + ? { kind: 'session', sessionId: this.sessionId } + : { kind: 'subagent', ...this.address } } } /** Convert one wire history row into the assembler's transport-neutral input. */ -function conversationInput(entry: HistoryEntry): ConversationEventInput { - return { event: entry.event, view: entry.view } +function conversationInput(entry: SessionEventEntry): ConversationEventInput { + return { + event: entry.event as SessionEvent, + view: entry.view, + } +} + +/** Convert a terminal Session stream failure to the Client error vocabulary. */ +function openFailure(error: unknown): ClientFailure { + const failure = sessionStreamFailure(error) + if (failure !== undefined) return failure as SessionError + const folded = transportError(error) + /* v8 ignore next -- transportError never returns an ok result. */ + if (folded.ok) throw new Error('transportError returned an unexpected success') + return folded.error +} + +/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */ +function toSessionResult(result: RemoteResult): ClientResult { + return result.ok ? result : { ok: false, error: result.error as SessionError } } /** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */ diff --git a/packages/client/runtime/tests/client-apply.client.spec.ts b/packages/client/runtime/tests/client-apply.client.spec.ts index 199181fe2e..329536541c 100644 --- a/packages/client/runtime/tests/client-apply.client.spec.ts +++ b/packages/client/runtime/tests/client-apply.client.spec.ts @@ -1,33 +1,37 @@ /** * Runtime plugin browser-half apply: slots + object services mounting over the - * connection handle, stream-loop sink wiring into the object layer, and the - * fiber-scoped loop teardown. + * connection handle, Remote stream wiring into the object layer, and + * fiber-scoped stream teardown. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' -import type { ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client' -import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' +import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-api-session-controller/client' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts' import { Session } from '../src/client/sessions/session.ts' -import type { SessionRuntime } from '../src/client/sessions/service.ts' -import type { WorkspaceRuntime } from '../src/client/workspaces/service.ts' +import { SessionRuntime } from '../src/client/sessions/service.ts' import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts' interface Bench { ctx: Context api: FakeApiClient - sinks: ConnectionSinks | undefined - stopped: number + start: ReturnType> + dispatchRemote(event: string, args: readonly unknown[]): void } -async function mount(): Promise { +async function mount(configure?: (api: FakeApiClient) => void): Promise { const ctx = new Context() await ctx.plugin(TypertRegistry) const api = new FakeApiClient() - const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } + configure?.(api) + const listeners = new Map void>>() + const dispatchRemote = (event: string, args: readonly unknown[]): void => { + for (const listener of listeners.get(event) ?? []) listener(...args as never[]) + } + const start = vi.fn(() => ({ stop: () => {} })) + const bench: Bench = { ctx, api, start, dispatchRemote } const handle: ConnectionHandle = { api, isLoopback: true, @@ -38,14 +42,23 @@ async function mount(): Promise { rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), }, - start: (sinks) => { - bench.sinks = sinks - return { stop: () => { bench.stopped += 1 } } - }, + registerGenerationSource: () => () => {}, + start, } + const remote = fakeRemote(api) ctx.reflect.provide('connection', handle) - ctx.reflect.provide('remote', {}) - ctx.reflect.provide('remote.commands', fakeRemote().commands) + ctx.reflect.provide('remote', { + ...remote, + $on: (event: string, listener: (...args: never[]) => void) => { + const eventListeners = listeners.get(event) ?? new Set() + eventListeners.add(listener) + listeners.set(event, eventListeners) + return () => { eventListeners.delete(listener) } + }, + }) + ctx.reflect.provide('remote.commands', remote.commands) + ctx.reflect.provide('remote.session', remote.session) + ctx.reflect.provide('remote.workspace', remote.workspace) await ctx.plugin(RuntimeClient).await() return bench } @@ -55,7 +68,18 @@ async function flushMicrotasks(): Promise { } describe('runtime client apply', () => { - it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => { + it('refreshes Sessions on every Gateway connection generation', async () => { + const refresh = vi.spyOn(SessionRuntime.prototype, 'handleConnected') + const bench = await mount() + + bench.ctx.emit('connection/reset') + bench.ctx.emit('connection/reset') + + expect(refresh).toHaveBeenCalledTimes(2) + refresh.mockRestore() + }) + + it('mounts slots, Sessions, and Workspaces and routes their independent streams', async () => { const bench = await mount() expect(bench.ctx.get('slots') !== undefined).toBe(true) // The built-in 'root' declaration ships with this package's SlotRegistry @@ -68,52 +92,53 @@ describe('runtime client apply', () => { // The bound the wire schema enforces, not a per-connection negotiation. expect((sessions as SessionRuntime).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT) if (workspaces === undefined) throw new Error('WorkspaceRuntime missing after runtime apply') - expect(bench.sinks).toBeDefined() + expect(bench.start).not.toHaveBeenCalled() - // Frame sinks reach the object layer: a host session-added lands in the list store. - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r1' as never, - payload: { type: 'host/session-added', blank: true, sessionId: 's-new' } as never, - }) + // Session Remote events reach the object layer and land in the list store. + bench.dispatchRemote('api-session/added', [{ + sessionId: 's-new', updatedAt: 1, running: false, blank: true, + }]) await Promise.resolve() expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new') - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r-workspace' as never, - payload: { - type: 'host/workspace-changed', - workspace: { - workspaceId: 'w-new', path: '/w/new', title: 'new', sessionIds: [], - createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', - }, - } as never, + await flushMicrotasks() + bench.api.pushWorkspace({ + type: 'upsert', + workspace: { + workspaceId: 'w-new' as never, path: '/w/new', title: 'new', sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }, }) - await Promise.resolve() + await flushMicrotasks() expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new') - // Mux sink and onConnected route without throwing (manager semantics own the behavior). - bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) + // Gateway generation publication routes without throwing. + bench.ctx.emit('connection/reset') }) it('selects the recent Workspace once when the first baselines have no current session', async () => { - const bench = await mount() - bench.api.onWorkspaceList = () => Promise.resolve(ok({ - items: [{ - workspaceId: 'w-recent', path: '/w/recent', title: 'recent', sessionIds: [], - createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', - }] as never[], - })) - bench.api.onList = () => Promise.resolve(ok({ items: [] })) + const bench = await mount((api) => { + api.workspaceBaseline = { + items: [{ + workspaceId: 'w-recent', path: '/w/recent', title: 'recent', sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }] as never[], + archivedSessionIds: [], + } + api.onList = () => Promise.resolve(ok({ items: [] })) + }) - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) - await flushMicrotasks() + bench.ctx.emit('connection/reset') const sessions = bench.ctx.get('sessions') as SessionRuntime - const workspaces = bench.ctx.get('workspaces') as WorkspaceRuntime - expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }]) + await vi.waitFor(() => { + expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }]) + }) expect(sessions.list.getSnapshot().current).toBe('fk-new') sessions.clear() - await workspaces.refresh() + bench.api.pushWorkspace({ + type: 'upsert', + workspace: bench.api.workspaceBaseline.items[0] as never, + }) await flushMicrotasks() expect(sessions.list.getSnapshot().current).toBeUndefined() expect(bench.api.callsOf('session.create')).toHaveLength(1) @@ -122,10 +147,9 @@ describe('runtime client apply', () => { it('wires registry changes into resident Sessions during the runtime apply pass', async () => { const bench = await mount() const sessions = bench.ctx.get('sessions') as SessionRuntime - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r-registry' as never, - payload: { type: 'host/session-added', blank: true, sessionId: 's-registry' } as never, - }) + bench.dispatchRemote('api-session/added', [{ + sessionId: 's-registry', updatedAt: 1, running: false, blank: true, + }]) await flushMicrotasks() expect(sessions.binding('s-registry' as never)).toBeDefined() const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') @@ -145,12 +169,9 @@ describe('runtime client apply', () => { rebuild.mockRestore() }) - it('stops the stream loop when the plugin fiber unloads', async () => { + it('does not own the Connection loop and closes its Remote streams on unload', async () => { const bench = await mount() - const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client')) - // Dispose the whole tree: the ctx.effect teardown must call loop.stop exactly once. await bench.ctx.fiber.dispose() - expect(bench.stopped).toBe(1) - void fiber + expect(bench.start).not.toHaveBeenCalled() }) }) diff --git a/packages/client/runtime/tests/conversation-registry.client.spec.ts b/packages/client/runtime/tests/conversation-registry.client.spec.ts index 4f05a3b15b..b7c9d149bc 100644 --- a/packages/client/runtime/tests/conversation-registry.client.spec.ts +++ b/packages/client/runtime/tests/conversation-registry.client.spec.ts @@ -145,7 +145,7 @@ describe('Conversation registries', () => { api.onList = () => Promise.resolve(ok({ items: [{ sessionId, updatedAt: 1, running: false, blank: true }], }) as never) - const sessions = new SessionRuntime(ctx, api, fakeRemote()) + const sessions = new SessionRuntime(ctx, api, fakeRemote(api)) await sessions.refresh() await Promise.resolve() sessions.scope(sessionId) diff --git a/packages/client/runtime/tests/fake-api.client.ts b/packages/client/runtime/tests/fake-api.client.ts index e5bdaa5f66..80d5c3986d 100644 --- a/packages/client/runtime/tests/fake-api.client.ts +++ b/packages/client/runtime/tests/fake-api.client.ts @@ -1,14 +1,41 @@ // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and -// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. +// deferred-controlled timing). Streams are hand pumps: pushFollow/pushControl/pushWorkspace. import type { - ClientResponse, HostFrame, IApiClient, ModelSelection, MuxFrame, - RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, + IApiClient, ModelSelection, + RpcError, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-api-remotes/client' +import type { + SessionAddress, + SessionControlBaseline, + SessionControlFrame, + SessionFollowFrame, + SessionFollowRequest, + SessionPage, + SessionPageRequest, + SessionRespondReceipt, + SessionRespondRequest, +} from '@deepseek-ai/dsh-api-session-controller/types' +import type { WorkspaceRemote } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { WorkspaceError, WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-controller/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { + RemoteStream, + type RemoteStreamOptions, +} from '@deepseek-ai/dsh-api-gateway/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionRemotes } from '../src/client/sessions/remotes.ts' +const AVAILABLE_STREAM_CONNECTION = { + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, + }), + subscribe: () => () => {}, + }, +} + /** Programmable-default workspace row (branded id, ISO-ish times). */ function fakeWorkspace(id: string, over: Partial = {}): WorkspaceView { return { @@ -22,6 +49,16 @@ function fakeWorkspace(id: string, over: Partial = {}): Workspace } } +function addressSessionId(address: SessionAddress): SessionId { + return address.kind === 'session' ? address.sessionId : address.childSessionId +} + +function addressKey(address: SessionAddress): string { + return address.kind === 'session' + ? `session:${address.sessionId}` + : `subagent:${address.parentSessionId}:${address.childSessionId}:${address.mode}` +} + export interface Deferred { promise: Promise resolve(value: T): void @@ -49,10 +86,28 @@ export function err(error: RpcError): RpcResponse { return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } } } -type StreamItem = { kind: 'frame'; envelope: RpcRequest } | { kind: 'end' } | { kind: 'fail'; error: unknown } +/** Successful generated Remote result for programmable domain fakes. */ +export function remoteOk(value: T): RemoteResult { + return { ok: true, value } +} -interface StreamConn { - feed(item: StreamItem): void +/** Workspace business failure returned by a generated Remote fake. */ +export function workspaceErr(error: WorkspaceError): RemoteResult { + return { ok: false, error } +} + +type ValueStreamItem = + | { kind: 'frame'; value: F; delivered?: () => void } + | { kind: 'end' } + | { kind: 'fail'; error: unknown } + +interface ValueStreamConn { + feed(item: ValueStreamItem): void +} + +interface OpenValueStream { + readonly values: AsyncGenerator + dispose(): void } /** @@ -60,13 +115,10 @@ interface StreamConn { * a test that programs nothing sees an empty catalog and an unmatched line. * @returns the Remote namespaces the session cluster calls. */ -export function fakeRemote(): SessionRemotes { - return { - commands: { - list: () => Promise.resolve({ ok: true, value: [] }), - execute: () => Promise.resolve({ ok: true, value: undefined }), - }, - } +export type RuntimeRemotes = SessionRemotes & { readonly workspace: WorkspaceRemote } + +export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes { + return api.sessionRemotes() } export class FakeApiClient implements IApiClient { @@ -82,7 +134,7 @@ export class FakeApiClient implements IApiClient { onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ @@ -131,37 +183,27 @@ export class FakeApiClient implements IApiClient { onCreateDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: '/home/fake/new' })) - private readonly muxConns: StreamConn[] = [] - private readonly hostConns: StreamConn[] = [] - lastSearchSignal: AbortSignal | undefined - - // Parameters carry local structural annotations: the CI lint lane runs - // without built lib/, so IApiClient's indexed-access types collapse to any - // and inferred parameters would trip no-unsafe-argument. - readonly sessions: IApiClient['sessions'] = { - list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)), - search: (payload: unknown, signal?: AbortSignal) => { - this.lastSearchSignal = signal - return this.record('session.search', payload, this.onSearch(payload)) - }, - 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: { provider: string; model: string }) => - this.record('session.selectModel', payload, this.onSelectModel(payload)), - rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)), - fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)), - prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), - attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), - updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), - cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), + private readonly followConns = new Map[]>() + private readonly controlConns: ValueStreamConn[] = [] + private readonly workspaceConns: ValueStreamConn[] = [] + private readonly openingPages = new Map>>() + /** Optional Host opening cursor override for stale-page and reconnect tests. */ + followCursor: number | undefined + controlBaseline: SessionControlBaseline = { + queues: {}, + jobs: {}, + approvals: [], + questions: [], + projections: {}, } + workspaceBaseline: Extract['value'] = { + items: [], + archivedSessionIds: [], + } + lastSearchSignal: AbortSignal | undefined onSubagentList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) - onSubagentHistory: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ events: [], hasMore: false })) onSubagentPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ messageId: 'fake-message' as never })) @@ -170,7 +212,6 @@ export class FakeApiClient implements IApiClient { readonly subagents: IApiClient['subagents'] = { list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)), - history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)), prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)), interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)), } @@ -183,44 +224,23 @@ export class FakeApiClient implements IApiClient { openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), } - // The archive-set field defaults at the binding below so list stubs keep - // the pre-archive `{ items }` shape; a stub carrying the field wins. - onWorkspaceList: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ items: [] })) - onWorkspaceCreate: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) + onWorkspaceCreate: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true })) - onWorkspaceRename: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + onWorkspaceRename: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') })) - onWorkspaceDelete: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ deleted: true })) + onWorkspaceDelete: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ deleted: true })) - onWorkspaceInsertBefore: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspaceIds: [] })) + onWorkspaceInsertBefore: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspaceIds: [] })) - onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') })) - onWorkspaceArchiveSession: (payload: unknown) => Promise> = - payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) - - readonly workspace: IApiClient['workspace'] = { - list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload).then(response => ( - response.result.ok - ? { ...response, result: { ok: true as const, value: { archivedSessionIds: [] as never[], ...response.result.value } } } - : response - )) as ReturnType), - create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), - rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), - delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), - insertBefore: (payload: unknown) => - this.record('workspace.insertBefore', payload, this.onWorkspaceInsertBefore(payload)), - insertSessionBefore: (payload: unknown) => - this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), - archiveSession: (payload: unknown) => - this.record('workspace.archiveSession', payload, this.onWorkspaceArchiveSession(payload)), - } + onWorkspaceArchiveSession: (payload: unknown) => Promise> = + payload => Promise.resolve(remoteOk({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program requires-bearing catalogs and dual-address @@ -278,51 +298,98 @@ export class FakeApiClient implements IApiClient { discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), } - /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ - suppressStreamOpen = false + onRespond: (request: SessionRespondRequest) => Promise> = + () => Promise.resolve({ ok: true, value: { accepted: true } }) - /** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them. - * Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */ - holdStreamOpen = false - private heldOpens: (() => void)[] = [] - - releaseStreamOpens(): void { - const held = this.heldOpens - this.heldOpens = [] - for (const fire of held) fire() + /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */ + sessionRemotes(): RuntimeRemotes { + return { + $stream: (options: RemoteStreamOptions) => ( + new RemoteStream(AVAILABLE_STREAM_CONNECTION, options) + ), + commands: { + list: () => Promise.resolve({ ok: true, value: [] }), + execute: () => Promise.resolve({ ok: true, value: undefined }), + }, + session: { + list: payload => this.remoteResult('session.list', payload, this.onList(payload)), + search: (payload, signal) => { + this.lastSearchSignal = signal + return this.remoteResult('session.search', payload, this.onSearch(payload)) + }, + create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)), + models: payload => this.remoteResult('session.models', payload, this.onModels(payload)), + selectModel: payload => this.remoteResult('session.selectModel', payload, this.onSelectModel(payload)), + rename: payload => this.remoteResult('session.rename', payload, this.onRename(payload)), + fork: payload => this.remoteResult('session.fork', payload, this.onFork(payload)), + prompt: payload => this.remoteResult('session.prompt', payload, this.onPrompt(payload)), + attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)), + updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)), + cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)), + page: request => this.page(request), + follow: (request, signal) => this.openFollow(request, signal), + control: signal => this.openControl(signal), + respond: request => this.record('session.respond', request, this.onRespond(request)), + }, + workspace: { + create: payload => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), + rename: payload => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + delete: payload => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), + insertBefore: payload => this.record( + 'workspace.insertBefore', + payload, + this.onWorkspaceInsertBefore(payload), + ), + insertSessionBefore: payload => this.record( + 'workspace.insertSessionBefore', + payload, + this.onWorkspaceInsertSessionBefore(payload), + ), + archiveSession: payload => this.record( + 'workspace.archiveSession', + payload, + this.onWorkspaceArchiveSession(payload), + ), + follow: signal => this.openWorkspace(signal), + }, + } } - readonly events: IApiClient['events'] = { - mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.muxConns, signal, onOpen), - host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen), + /** Push one live Session event to every follower of that Session. */ + async pushFollow( + sessionId: SessionId, + frame: Extract, + ): Promise { + await Promise.all([...(this.followConns.get(sessionId) ?? [])].map(conn => new Promise((resolve) => { + conn.feed({ kind: 'frame', value: frame, delivered: resolve }) + }))) } - onRespond: (message: ClientResponse) => Promise = () => Promise.resolve({ accepted: true }) - - respond(message: ClientResponse): Promise { - return this.record('respond', message, this.onRespond(message)) + /** Push one Host-wide control update. */ + pushControl(frame: Exclude): void { + for (const conn of [...this.controlConns]) conn.feed({ kind: 'frame', value: frame }) } - /** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */ - pushMux(frame: MuxFrame, rpcId?: string): void { - for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } }) - } - - pushHost(frame: HostFrame, rpcId?: string): void { - for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } }) + /** Push one Workspace projection increment. */ + pushWorkspace(frame: Exclude): void { + for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'frame', value: frame }) } /** End (clean close) or fail (throw) every open stream — reconnect-path material. */ endStreams(): void { - for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' }) + for (const conns of this.followConns.values()) { + for (const conn of [...conns]) conn.feed({ kind: 'end' }) + } + for (const conn of [...this.controlConns]) conn.feed({ kind: 'end' }) + for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'end' }) } failStreams(error: unknown): void { - for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error }) - } - - get openMuxCount(): number { - return this.muxConns.length + for (const conns of this.followConns.values()) { + for (const conn of [...conns]) conn.feed({ kind: 'fail', error }) + } + for (const conn of [...this.controlConns]) conn.feed({ kind: 'fail', error }) + for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'fail', error }) } callsOf(method: string): unknown[] { @@ -334,34 +401,144 @@ export class FakeApiClient implements IApiClient { return response } - private async *openStream(registry: StreamConn[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator> { - const inbox: StreamItem[] = [] + private async remoteResult( + method: string, + payload: unknown, + response: Promise>, + ): Promise> { + return (await this.record(method, payload, response)).result + } + + private page(request: SessionPageRequest): Promise> { + const key = addressKey(request.address) + if (request.beforeSeq === undefined && request.maxMessages === 50) { + const opening = this.openingPages.get(key) + if (opening !== undefined) { + this.openingPages.delete(key) + return opening + } + } + return this.fetchPage(request) + } + + private fetchPage(request: SessionPageRequest): Promise> { + const sessionId = addressSessionId(request.address) + const payload = request.address.kind === 'session' + ? { + sessionId, + ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, + ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, + } + : { + parentSessionId: request.address.parentSessionId, + childSessionId: request.address.childSessionId, + mode: request.address.mode, + ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, + ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, + } + const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history' + return this.remoteResult(method, payload, this.onHistory({ + sessionId, + ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, + ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, + })) + } + + private async *openFollow( + request: SessionFollowRequest, + signal: AbortSignal = new AbortController().signal, + ): AsyncGenerator { + const sessionId = addressSessionId(request.address) + const key = addressKey(request.address) + const initialPage = this.fetchPage({ address: request.address, maxMessages: 50 }) + this.openingPages.set(key, initialPage) + const conns = this.followConns.get(sessionId) ?? [] + if (!this.followConns.has(sessionId)) this.followConns.set(sessionId, conns) + const stream = this.openValueStream(conns, signal) + try { + const page = await initialPage + const cursor = this.followCursor ?? (page.ok ? page.value.events.at(-1)?.event.seq ?? -1 : -1) + yield { type: 'opened', cursor } + yield* stream.values + } finally { + stream.dispose() + this.openingPages.delete(key) + } + } + + private async *openControl( + signal: AbortSignal = new AbortController().signal, + ): AsyncGenerator { + const stream = this.openValueStream(this.controlConns, signal) + try { + yield { type: 'baseline', value: this.controlBaseline } + yield* stream.values + } finally { + stream.dispose() + } + } + + private async *openWorkspace( + signal: AbortSignal = new AbortController().signal, + ): AsyncGenerator { + const stream = this.openValueStream(this.workspaceConns, signal) + try { + yield { type: 'baseline', value: this.workspaceBaseline } + yield* stream.values + } finally { + stream.dispose() + } + } + + private openValueStream( + registry: ValueStreamConn[], + signal: AbortSignal, + ): OpenValueStream { + const inbox: ValueStreamItem[] = [] let wake: (() => void) | null = null - const conn: StreamConn = { + let inFlightDelivered: (() => void) | undefined + let disposed = false + const conn: ValueStreamConn = { feed: (item) => { inbox.push(item) wake?.() }, } registry.push(conn) - if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen) - else if (!this.suppressStreamOpen) onOpen?.() - try { - while (!signal.aborted) { - while (inbox.length > 0) { - const item = inbox.shift() as StreamItem - if (item.kind === 'end') return - if (item.kind === 'fail') throw item.error - yield item.envelope - } - await new Promise((resolve) => { - wake = resolve - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) - wake = null + const dispose = (): void => { + if (disposed) return + disposed = true + inFlightDelivered?.() + for (const item of inbox) { + if (item.kind === 'frame') item.delivered?.() } - } finally { - registry.splice(registry.indexOf(conn), 1) + const index = registry.indexOf(conn) + if (index >= 0) registry.splice(index, 1) + wake?.() } + const values = (async function* (): AsyncGenerator { + try { + while (!signal.aborted && !disposed) { + while (inbox.length > 0) { + const item = inbox.shift() as ValueStreamItem + if (item.kind === 'end') return + if (item.kind === 'fail') throw item.error + inFlightDelivered = item.delivered + yield item.value + inFlightDelivered?.() + inFlightDelivered = undefined + } + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + wake = null + } + } finally { + dispose() + } + })() + return { values, dispose } } + } diff --git a/packages/client/runtime/tests/manager.client.spec.ts b/packages/client/runtime/tests/manager.client.spec.ts index d0594032df..1f657a484e 100644 --- a/packages/client/runtime/tests/manager.client.spec.ts +++ b/packages/client/runtime/tests/manager.client.spec.ts @@ -5,9 +5,16 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { + SessionApprovalRequest, + SessionControlFrame, + SessionInteractionId, + SessionQuestionRequest, +} from '@deepseek-ai/dsh-api-session-controller/types' +import type {} from '@deepseek-ai/dsh-session-title/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' -import { entries, ev, plainTurn } from './event-script.client.ts' +import { entries, plainTurn } from './event-script.client.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId @@ -16,6 +23,7 @@ type SummaryOver = Partial<{ updatedAt: number running: boolean blank: boolean + cwd: string parentSessionId: SessionId origin: 'subagent' }> @@ -24,24 +32,53 @@ function summary(sessionId: SessionId, over: SummaryOver = {}) { return { sessionId, updatedAt: 100, running: false, blank: false, ...over } } +function makeManager(): SessionManager { + const api = new FakeApiClient() + return new SessionManager(api, fakeRemote(api)) +} + +function interactionId(value: string): SessionInteractionId { + return value as SessionInteractionId +} + +function approvalRequest( + id: string, + approvalId: string, + sessionId: SessionId = S1, +): SessionApprovalRequest & { type: 'approval/requested' } { + return { + type: 'approval/requested', + interactionId: interactionId(id), + sessionId, + approvalId: approvalId as never, + toolName: 'rm', + } +} + +function questionRequest( + id: string, + questions: SessionQuestionRequest['questions'] = [], + sessionId: SessionId = S1, +): SessionQuestionRequest & { type: 'question/requested' } { + return { type: 'question/requested', interactionId: interactionId(id), sessionId, questions } +} + describe('instances', () => { it('lazily builds one resident instance per id and syncs the running bit from the list', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() const session = manager.get(S1) expect(manager.get(S1)).toBe(session) // resident: same instance forever expect(session.getSnapshot().running).toBe(true) // list preceded instantiation }) - it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => { + it('replays stable approval state on instantiation', () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - // Uninstantiated: approval buffers, plain session/event drops. - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } }) + const manager = new SessionManager(api, fakeRemote(api)) + manager.handleControlFrame(approvalRequest('ra', 'ap1')) + manager.handleControlFrame(approvalRequest('ra', 'ap1')) const session = manager.get(S1) expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }]) // Buffer cleared: a second instantiation of another id gets nothing. @@ -50,16 +87,16 @@ describe('instances', () => { it('retains every live answerable request and compacts resolutions before instantiation', () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + const manager = new SessionManager(api, fakeRemote(api)) + manager.handleSessionAdded(summary(S1)) for (let i = 0; i < 40; i++) { - manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) + manager.handleControlFrame(questionRequest(`q${i}`)) } expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') for (let i = 0; i < 40; i++) { - manager.handleMuxEnvelope({ - rpcId: `r${i}` as never, - payload: { type: 'question/resolved', sessionId: S1, questionRpcId: `q${i}` as never, outcome: 'answered' }, + manager.handleControlFrame({ + type: 'question/resolved', sessionId: S1, + interactionId: interactionId(`q${i}`), outcome: 'answered', }) } expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() @@ -67,10 +104,10 @@ describe('instances', () => { }) it('drops buffered answerable requests on session removal', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) + const manager = makeManager() // Removed session: buffered frames must not replay on a future instantiation. - manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } }) - manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) + manager.handleControlFrame(questionRequest('qz', [], S2)) + manager.handleSessionRemoved(S2) expect(manager.get(S2).getSnapshot().pending).toEqual([]) }) }) @@ -80,7 +117,7 @@ describe('list lifecycle', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const first = manager.refreshList() const second = manager.refreshList() expect(manager.getListSnapshot().state).toBe('loading') @@ -96,12 +133,9 @@ describe('list lifecycle', () => { const api = new FakeApiClient() const first = deferred>>() api.onList = () => first.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const hydration = manager.refreshList() - manager.handleHostEnvelope({ - rpcId: 'during-first' as never, - payload: { type: 'host/session-added', blank: true, sessionId: S2 }, - }) + manager.handleSessionAdded(summary(S2, { blank: true })) first.resolve(ok({ items: [summary(S1)] as never[] })) await hydration expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) @@ -113,50 +147,20 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) }) - it('advances list activity only for direct user messages', async () => { + it('advances list activity from the filtered Host notification', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() - // Both a new prompt and an admitted steer land as a user-sourced message. - const activity = { ...ev.user(10, 'new'), time: 500 } - manager.handleMuxEnvelope({ - rpcId: 'activity' as never, - payload: { type: 'session/event', sessionId: S1, event: activity }, - }) - expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) - - manager.handleMuxEnvelope({ - rpcId: 'older' as never, - payload: { type: 'session/event', sessionId: S1, event: { ...activity, time: 400 } }, - }) - manager.handleMuxEnvelope({ - rpcId: 'assistant' as never, - payload: { type: 'session/event', sessionId: S1, event: { ...ev.assistant(11, 0, 'reply'), time: 600 } }, - }) - - const injected = ev.user(12, 'context') - if (injected.type !== 'user/message') throw new Error('user builder returned another event type') - manager.handleMuxEnvelope({ - rpcId: 'injected' as never, - payload: { - type: 'session/event', - sessionId: S1, - event: { - ...injected, - time: 700, - data: { ...injected.data, source: { kind: 'plugin', plugin: 'test' } }, - }, - }, - }) + manager.handleSessionActivity(S1, 500) expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) }) it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) // A failed pull does not step the arrival phase: still pending. @@ -165,7 +169,7 @@ describe('list lifecycle', () => { it('phase steps pending → ready on the first successful pull and never returns', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) expect(manager.getListSnapshot().phase).toBe('pending') await manager.refreshList() expect(manager.getListSnapshot().phase).toBe('ready') @@ -184,7 +188,7 @@ describe('list lifecycle', () => { it('merges create into the list immediately without waiting for a refresh', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S2 })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const result = await manager.create() expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2]) @@ -192,16 +196,13 @@ describe('list lifecycle', () => { it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - const titleFrame = (rpcId: string, title: string, seq: number) => { - manager.handleMuxEnvelope({ - rpcId: rpcId as never, - payload: { type: 'session/projection', sessionId: S1, key: 'title', value: title, seq } as never, - }) + const manager = new SessionManager(api, fakeRemote(api)) + const titleFrame = (title: string, seq: number) => { + manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq }) } - titleFrame('title-new', 'Newest', 4) - titleFrame('title-stale', 'Stale', 3) - titleFrame('title-equal', 'Equal', 4) + titleFrame('Newest', 4) + titleFrame('Stale', 3) + titleFrame('Equal', 4) api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[], })) @@ -212,18 +213,17 @@ describe('list lifecycle', () => { expect(titled.items[0]?.title).toBe('Newest') expect(titled.items[1]?.title).toBeUndefined() - manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) - manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) + manager.handleSessionRemoved(S1) + manager.handleSessionAdded(summary(S1, { blank: true })) expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) // A push frame landed before the list (S2's title is newer than the block's cut). - manager.handleMuxEnvelope({ - rpcId: 'push-newer' as never, - payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9, }) api.onList = () => Promise.resolve(ok({ items: [ @@ -242,23 +242,33 @@ describe('list lifecycle', () => { it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() - const frame = (rpcId: string, payload: object) => { - manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never }) - } - frame('title-unflushed', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 }) + const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) } + frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 }) // The durable baseline says the host only knows up to seq 2: the phantom // row rode lost state and must drop, or last-wins pins it forever. - frame('subscribed-recovered', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 }) + frame({ + type: 'baseline', + value: { + queues: {}, jobs: {}, approvals: [], questions: [], + projections: { [S1]: { asOfSeq: 2, values: {} } }, + }, + }) expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() - frame('title-durable', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 }) + frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 }) expect(manager.getListSnapshot().items[0]?.title).toBe('Durable') // A baseline at or past the row's seq keeps it (nothing phantom to drop). - frame('subscribed-current', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 }) + frame({ + type: 'baseline', + value: { + queues: {}, jobs: {}, approvals: [], questions: [], + projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } }, + }, + }) expect(manager.getListSnapshot().items[0]?.title).toBe('Durable') }) }) @@ -270,7 +280,7 @@ describe('search', () => { items: [{ sessionId: S1, snippet: 'matching excerpt' }], hasMore: true, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const signal = new AbortController().signal await expect(manager.search('exact phrase', signal)).resolves.toEqual({ @@ -286,7 +296,7 @@ describe('search', () => { it('preserves business errors and folds transport failures', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) api.onSearch = () => Promise.resolve(err({ code: 'internal', message: 'index unavailable', @@ -306,23 +316,23 @@ describe('search', () => { }) }) -describe('host frame routing', () => { - it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => { +describe('Host Remote event routing', () => { + it('adds/removes/flips sessions and keeps removed instances resident', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) - manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored + const manager = new SessionManager(api, fakeRemote(api)) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionAdded(summary(S1, { blank: true })) // dup: ignored expect(manager.getListSnapshot().items).toHaveLength(1) const session = manager.get(S1) - manager.handleHostEnvelope({ rpcId: 'h3' as never, payload: { type: 'host/session-status', sessionId: S1, running: true } }) + manager.handleSessionStatus(S1, true) expect(session.getSnapshot().running).toBe(true) expect(manager.getListSnapshot().items[0]?.running).toBe(true) - manager.handleHostEnvelope({ rpcId: 'h4' as never, payload: { type: 'host/agent-error', sessionId: S1, message: '炸了' } }) + manager.handleSessionError(S1, '炸了') expect(session.getSnapshot().lastAgentError).toBe('炸了') - manager.handleHostEnvelope({ rpcId: 'h5' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + manager.handleSessionRemoved(S1) expect(manager.getListSnapshot().items).toHaveLength(0) expect(session.getSnapshot().removed).toBe(true) expect(manager.get(S1)).toBe(session) // resident-instance rule survives removal @@ -343,7 +353,7 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() await manager.refreshSubagents(S1) manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) @@ -380,19 +390,13 @@ describe('subagent catalogs', () => { expect(api.callsOf('session.history')).toEqual([]) expect(api.callsOf('session.prompt')).toEqual([]) const listCalls = api.callsOf('subagent.list').length - manager.handleHostEnvelope({ - rpcId: 'child-complete' as never, - payload: { type: 'host/session-status', sessionId: S2, running: false }, - }) + manager.handleSessionStatus(S2, false) expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({ kind: 'child', id: S2, activity: 'inactive', }) expect(api.callsOf('subagent.list')).toHaveLength(listCalls) - manager.handleHostEnvelope({ - rpcId: 'child-detached' as never, - payload: { type: 'host/session-removed', sessionId: S2 }, - }) + manager.handleSessionRemoved(S2) expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({ origin: 'subagent', parentSessionId: S1, running: false, }) @@ -408,33 +412,18 @@ describe('subagent catalogs', () => { vi.useFakeTimers() try { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshSubagents(S1) manager.setSubagentCatalogOpen(S1, true) await Promise.resolve() const baseline = api.callsOf('subagent.list').length - manager.handleHostEnvelope({ - rpcId: 'child-added' as never, - payload: { - type: 'host/session-added', sessionId: S2, parentSessionId: S1, blank: false, - }, - }) - manager.handleHostEnvelope({ - rpcId: 'child-added-again' as never, - payload: { - type: 'host/session-added', sessionId: 'fk-m3' as SessionId, parentSessionId: S1, blank: false, - }, - }) + manager.handleSessionAdded(summary(S2, { parentSessionId: S1 })) + manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 })) await vi.advanceTimersByTimeAsync(50) expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1) manager.setSubagentCatalogOpen(S1, false) - manager.handleHostEnvelope({ - rpcId: 'child-added-closed' as never, - payload: { - type: 'host/session-added', sessionId: 'fk-m4' as SessionId, parentSessionId: S1, blank: false, - }, - }) + manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 })) await vi.advanceTimersByTimeAsync(50) expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1) } finally { @@ -458,23 +447,13 @@ describe('subagent catalogs', () => { ] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshSubagents(root) - manager.handleHostEnvelope({ - rpcId: 'nested-subagent' as never, - payload: { - type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId, - parentSessionId: S1, origin: 'subagent', blank: false, - }, - }) - manager.handleHostEnvelope({ - rpcId: 'ordinary-fork' as never, - payload: { - type: 'host/session-added', sessionId: 'fk-fork' as SessionId, - parentSessionId: S2, blank: false, - }, - }) + manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { + parentSessionId: S1, origin: 'subagent', + })) + manager.handleSessionAdded(summary('fk-fork' as SessionId, { parentSessionId: S2 })) expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ { kind: 'child', id: S1, hasChildren: true }, @@ -487,16 +466,12 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshSubagents(root) - manager.handleHostEnvelope({ - rpcId: 'nested-subagent' as never, - payload: { - type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId, - parentSessionId: S1, origin: 'subagent', blank: false, - }, - }) + manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { + parentSessionId: S1, origin: 'subagent', + })) response.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'parent', @@ -528,17 +503,11 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshSubagents(root) - manager.handleHostEnvelope({ - rpcId: 'child-stopped' as never, - payload: { type: 'host/session-status', sessionId: S1, running: false }, - }) - manager.handleHostEnvelope({ - rpcId: 'child-started' as never, - payload: { type: 'host/session-status', sessionId: S2, running: true }, - }) + manager.handleSessionStatus(S1, false) + manager.handleSessionStatus(S2, true) response.resolve(ok({ entries: [ { @@ -569,13 +538,10 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshSubagents(S1) - manager.handleHostEnvelope({ - rpcId: 'child-detached' as never, - payload: { type: 'host/session-removed', sessionId: S2 }, - }) + manager.handleSessionRemoved(S2) expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([ { kind: 'child', id: S2, activity: 'inactive' }, @@ -587,7 +553,7 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const first = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshSubagents(root) expect(manager.refreshSubagents(root)).toBe(refresh) @@ -606,19 +572,14 @@ describe('subagent catalogs', () => { const first = deferred>>() const second = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api, fakeRemote(), root) + const manager = new SessionManager(api, fakeRemote(api), root) const refresh = manager.refreshSubagents(root) // A membership frame arrives while the pull is in flight; the debounced // refresh it schedules fires 50ms later and is coalesced into the pull — // which was requested before the new child existed. The stale mark must // queue one trailing pull carrying the change. - manager.handleHostEnvelope({ - rpcId: 'child-added' as never, - payload: { - type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false, - }, - }) + manager.handleSessionAdded(summary(S2, { parentSessionId: root })) await vi.advanceTimersByTimeAsync(50) api.onSubagentList = () => second.promise first.resolve(ok({ @@ -664,7 +625,7 @@ describe('subagent catalogs', () => { }) const first = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshSubagents(root) first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await refresh @@ -675,10 +636,7 @@ describe('subagent catalogs', () => { const mid = deferred>>() api.onSubagentList = () => mid.promise const midRefresh = manager.refreshSubagents(root) - manager.handleHostEnvelope({ - rpcId: 'parent-removed-mid-pull' as never, - payload: { type: 'host/session-removed', sessionId: root }, - }) + manager.handleSessionRemoved(root) const trailing = deferred>>() api.onSubagentList = () => trailing.promise mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) @@ -711,15 +669,12 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshSubagents(root) manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) - manager.handleHostEnvelope({ - rpcId: 'parent-removed' as never, - payload: { type: 'host/session-removed', sessionId: root }, - }) + manager.handleSessionRemoved(root) expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) @@ -730,14 +685,14 @@ describe('remaining branches', () => { it('refreshList folds a transport throw into the error state', async () => { const api = new FakeApiClient() api.onList = () => Promise.reject(new Error('list wire down')) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } }) }) it('refreshList pushes running bits down to already-instantiated sessions', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const session = manager.get(S1) api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) await manager.refreshList() @@ -747,7 +702,7 @@ describe('remaining branches', () => { it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.create({ cwd: '/tmp/w', sessionId: S1 }) expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) @@ -767,7 +722,7 @@ describe('remaining branches', () => { message: 'published but unattached', details: { sessionId: S1, workspaceId: 'w1' }, } as never)) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) @@ -781,7 +736,7 @@ describe('remaining branches', () => { message: 'forked but unattached', details: { sessionId: S2, workspaceId: 'w1' }, } as never)) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const result = await manager.fork({ sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ @@ -794,28 +749,22 @@ describe('remaining branches', () => { it('reconciles a preallocated id after an ordinary transport failure', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.reject(new Error('response lost')) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) expect(manager.getListSnapshot().items).toEqual([]) - manager.handleHostEnvelope({ - rpcId: 'published-later' as never, - payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' }, - }) + manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' })) expect(manager.getListSnapshot().items).toEqual([ expect.objectContaining({ sessionId: S1, cwd: '/w/one' }), ]) - manager.handleHostEnvelope({ - rpcId: 'duplicate-frame' as never, - payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' }, - }) + manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' })) expect(manager.getListSnapshot().items).toHaveLength(1) }) it('subscribe notifies on list changes and stops after unsubscribe', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) let notified = 0 const unsubscribe = manager.subscribe(() => { notified++ }) await manager.refreshList() @@ -823,53 +772,46 @@ describe('remaining branches', () => { expect(notified).toBeGreaterThan(0) const seen = notified unsubscribe() - manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) + manager.handleSessionAdded(summary(S1, { blank: true })) await new Promise(resolve => setTimeout(resolve, 0)) expect(notified).toBe(seen) }) - it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => { + it('dispatches control and Host events to instantiated sessions', () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) - manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) - manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never }) + const manager = new SessionManager(api, fakeRemote(api)) const session = manager.get(S1) - manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) + manager.handleControlFrame(questionRequest('q1')) expect(session.getSnapshot().pending).toMatchObject([{ kind: 'question' }]) // status flip for an unknown session only touches summaries (no crash). - manager.handleHostEnvelope({ rpcId: 'h9' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } }) - manager.handleHostEnvelope({ rpcId: 'ha' as never, payload: { type: 'host/agent-error', sessionId: S2, message: '无实例' } }) + manager.handleSessionStatus(S2, true) + manager.handleSessionError(S2, '无实例') }) it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() const before = manager.getListSnapshot() - manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } }) + manager.handleSessionStatus(S2, true) const after = manager.getListSnapshot() expect(after.items).not.toBe(before.items) const beforeS1 = before.items.find(e => e.sessionId === S1) const afterS1 = after.items.find(e => e.sessionId === S1) expect(afterS1).toBe(beforeS1) // untouched entry keeps identity (entryCache) // Same-order same-entries snapshot reuses the items array. - manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/agent-error', sessionId: S1, message: 'x' } }) + manager.handleSessionError(S1, 'x') expect(manager.getListSnapshot().items).toBe(after.items) }) - it('carries parentSessionId from host/session-added into the lineage row', () => { + it('carries parentSessionId from the added event into the lineage row', () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) - manager.handleHostEnvelope({ - rpcId: 'h2' as never, - payload: { - type: 'host/session-added', blank: true, sessionId: S2, - parentSessionId: S1, origin: 'subagent', - }, - }) + const manager = new SessionManager(api, fakeRemote(api)) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionAdded(summary(S2, { + blank: true, parentSessionId: S1, origin: 'subagent', + })) const items = manager.getListSnapshot().items expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, origin: 'subagent', depth: 1, @@ -878,14 +820,14 @@ describe('remaining branches', () => { }) describe('connected generation', () => { - it('refreshes the list and resyncs only opened instances', async () => { + it('refreshes query baselines without rebuilding independently resumed Session sources', async () => { const api = new FakeApiClient() api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const openedSession = manager.get(S1) await openedSession.open() manager.get(S2) // instantiated but never opened @@ -893,9 +835,8 @@ describe('connected generation', () => { manager.handleConnected() await vi.waitFor(() => { expect(api.callsOf('session.list').length).toBe(1) - // Only the opened instance repulls history; the cold one stays silent. - expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1) }) + expect(api.callsOf('session.history')).toHaveLength(historyCallsBefore) }) it('reloads the durable parent address for a restored child selection', async () => { @@ -903,7 +844,7 @@ describe('connected generation', () => { const address = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const, } - const manager = new SessionManager(api, fakeRemote(), S2, address) + const manager = new SessionManager(api, fakeRemote(api), S2, address) manager.handleConnected() @@ -916,43 +857,42 @@ describe('connected generation', () => { describe('pending-interaction list status', () => { it('tracks approval requests through replay and resolution without instantiation', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) + const manager = makeManager() + manager.handleSessionAdded(summary(S1)) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + manager.handleControlFrame(approvalRequest('ra', 'ap1')) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') - // Mux-open replay of the same question (same approvalId) is idempotent. - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + // Stream replay of the same stable interaction is idempotent. + manager.handleControlFrame(approvalRequest('ra', 'ap1')) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') - manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } }) + manager.handleControlFrame({ + type: 'approval/resolved', interactionId: interactionId('ra'), sessionId: S1, + approvalId: 'ap1' as never, outcome: 'allowed-once', + }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() }) - it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - manager.handleMuxEnvelope({ - rpcId: 'q1' as never, - payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, - }) + it('classifies ordinary questions and renderable plan reviews, then clears by interaction id', () => { + const manager = makeManager() + manager.handleSessionAdded(summary(S1)) + manager.handleControlFrame(questionRequest('q1', [{ id: 'name', question: 'Name?' }])) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') - manager.handleMuxEnvelope({ rpcId: 'qx' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) + manager.handleControlFrame({ + type: 'question/resolved', interactionId: interactionId('q1'), + sessionId: S1, outcome: 'answered', + }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() - manager.handleMuxEnvelope({ - rpcId: 'q2' as never, - payload: { - type: 'question/requested', - sessionId: S1, - questions: [{ - id: 'plan', question: 'Approve?', detail: '# Plan', - options: [{ label: 'Approve' }, { label: 'Refuse' }], - intent: { kind: 'plan-review', approve: 'Approve' }, - }], - }, - }) + manager.handleControlFrame(questionRequest('q2', [{ + id: 'plan', question: 'Approve?', detail: '# Plan', + options: [{ label: 'Approve' }, { label: 'Refuse' }], + intent: { kind: 'plan-review', approve: 'Approve' }, + }])) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review') - manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q2' as never, outcome: 'cancelled' } }) + manager.handleControlFrame({ + type: 'question/resolved', interactionId: interactionId('q2'), + sessionId: S1, outcome: 'cancelled', + }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() }) @@ -962,91 +902,85 @@ describe('pending-interaction list status', () => { ['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }], ['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }], ])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - manager.handleMuxEnvelope({ - rpcId: 'q-plan' as never, - payload: { - type: 'question/requested', sessionId: S1, - questions: [{ - id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }], - intent: { kind: 'plan-review', approve: 'Approve' }, - ...over, - }], - }, - }) + const manager = makeManager() + manager.handleSessionAdded(summary(S1)) + manager.handleControlFrame(questionRequest('q-plan', [{ + id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }], + intent: { kind: 'plan-review', approve: 'Approve' }, + ...over, + }])) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') }) it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) - manager.handleMuxEnvelope({ - rpcId: 'q1' as never, - payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, - }) + const manager = makeManager() + manager.handleSessionAdded(summary(S1)) + manager.handleControlFrame(approvalRequest('r1', 'a1')) + manager.handleControlFrame(questionRequest('q1', [{ id: 'name', question: 'Name?' }])) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') - manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) + manager.handleControlFrame({ + type: 'question/resolved', interactionId: interactionId('q1'), + sessionId: S1, outcome: 'answered', + }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') - manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } }) + manager.handleControlFrame({ + type: 'approval/resolved', interactionId: interactionId('r1'), sessionId: S1, + approvalId: 'a1' as never, outcome: 'rejected', + }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() - manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) - manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + manager.handleControlFrame(approvalRequest('r2', 'a2')) + manager.handleSessionRemoved(S1) expect(manager.getListSnapshot().items).toHaveLength(0) }) - it('drops stale status at generation death before replay re-adds live interactions', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + it('replaces stale interactions from the next control-stream baseline', () => { + const manager = makeManager() + manager.handleSessionAdded(summary(S1)) + manager.handleControlFrame(approvalRequest('ra', 'ap1')) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') - // Generation death clears (resolved-while-disconnected questions send no frame)… - manager.handleDisconnected() + manager.handleControlFrame({ + type: 'baseline', + value: { queues: {}, jobs: {}, approvals: [], questions: [], projections: {} }, + }) expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() - // …and a replayed frame arriving before onConnected (stream open precedes - // the readiness handshake) survives the later handleConnected untouched. - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + manager.handleControlFrame(approvalRequest('ra', 'ap1')) manager.handleConnected() expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') }) - it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - // Buffered pre-instantiation: an approval pair and a queued row. - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) - manager.handleDisconnected() - // Instantiate after the death sweep: no zombie interaction replays (the - // pendingBuffers held only dead-generation rpcIds), so the session mints - // no pending waits. + it('an empty baseline drops answerable state buffered before instantiation', () => { + const manager = makeManager() + manager.handleSessionAdded(summary(S1)) + manager.handleControlFrame(approvalRequest('ra', 'ap1')) + manager.handleControlFrame(questionRequest('q1')) + manager.handleControlFrame({ + type: 'baseline', + value: { queues: {}, jobs: {}, approvals: [], questions: [], projections: {} }, + }) const session = manager.get(S1) expect(session.getSnapshot().pending).toEqual([]) }) }) describe('completed reminder', () => { - const status = (rpcId: string, sessionId: SessionId, running: boolean) => ({ - rpcId: rpcId as never, - payload: { type: 'host/session-status' as const, sessionId, running }, - }) - const added = (rpcId: string, sessionId: SessionId) => ({ - rpcId: rpcId as never, - payload: { type: 'host/session-added' as const, sessionId, blank: false }, - }) + const status = (manager: SessionManager, sessionId: SessionId, running: boolean): void => { + manager.handleSessionStatus(sessionId, running) + } + const added = (manager: SessionManager, sessionId: SessionId): void => { + manager.handleSessionAdded(summary(sessionId)) + } const entry = (manager: SessionManager, sessionId: SessionId) => manager.getListSnapshot().items.find(item => item.sessionId === sessionId) it('arms on a running→idle flip of a non-selected session and clears on select', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope(added('h1', S1)) - manager.handleHostEnvelope(added('h2', S2)) + const manager = makeManager() + added(manager, S1) + added(manager, S2) manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) - manager.handleHostEnvelope(status('s1', S2, true)) - manager.handleHostEnvelope(status('s2', S2, false)) + status(manager, S2, true) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(true) // Opening the session consumes the reminder. manager.select(S2) @@ -1054,53 +988,53 @@ describe('completed reminder', () => { }) it('never arms for the session being watched and re-arms after a switch-away re-run', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope(added('h1', S1)) - manager.handleHostEnvelope(added('h2', S2)) + const manager = makeManager() + added(manager, S1) + added(manager, S2) manager.select(S2) - manager.handleHostEnvelope(status('s1', S2, true)) - manager.handleHostEnvelope(status('s2', S2, false)) + status(manager, S2, true) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder // Switch away; a fresh run completing again arms the reminder. manager.select(S1) - manager.handleHostEnvelope(status('s3', S2, true)) - manager.handleHostEnvelope(status('s4', S2, false)) + status(manager, S2, true) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(true) }) it('a re-run disarms the reminder while running and re-arms on its completion', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope(added('h1', S1)) - manager.handleHostEnvelope(added('h2', S2)) + const manager = makeManager() + added(manager, S1) + added(manager, S2) manager.select(S1) - manager.handleHostEnvelope(status('s1', S2, true)) - manager.handleHostEnvelope(status('s2', S2, false)) + status(manager, S2, true) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(true) // The user starts a new run without opening the session: running wins. - manager.handleHostEnvelope(status('s3', S2, true)) + status(manager, S2, true) expect(entry(manager, S2)?.completed).toBe(false) - manager.handleHostEnvelope(status('s4', S2, false)) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(true) }) it('session-removed drops the reminder and a re-add starts clean', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope(added('h1', S1)) - manager.handleHostEnvelope(added('h2', S2)) + const manager = makeManager() + added(manager, S1) + added(manager, S2) manager.select(S1) - manager.handleHostEnvelope(status('s1', S2, true)) - manager.handleHostEnvelope(status('s2', S2, false)) + status(manager, S2, true) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(true) - manager.handleHostEnvelope({ rpcId: 'rm' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) + manager.handleSessionRemoved(S2) expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined() - manager.handleHostEnvelope(added('h3', S2)) + added(manager, S2) expect(entry(manager, S2)?.completed).toBe(false) }) it('a list refresh carrying the running→idle transition arms the reminder', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) @@ -1112,7 +1046,7 @@ describe('completed reminder', () => { it('never arms for sessions already idle at first observation', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) @@ -1125,11 +1059,11 @@ describe('completed reminder', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshList() // The session finishes while the first pull is still in flight; the pull // response recorded it as running at pull time. - manager.handleHostEnvelope(status('s-mid', S2, false)) + status(manager, S2, false) gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) await refresh expect(entry(manager, S2)?.completed).toBe(true) @@ -1139,13 +1073,13 @@ describe('completed reminder', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshList() // The unknown session starts and finishes while the first pull is in // flight; the pull-time baseline recorded it idle, so the running→idle // edge lives entirely inside the replayed mutations. - manager.handleHostEnvelope(status('s-start', S2, true)) - manager.handleHostEnvelope(status('s-finish', S2, false)) + status(manager, S2, true) + status(manager, S2, false) gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) await refresh expect(entry(manager, S2)?.completed).toBe(true) @@ -1156,53 +1090,57 @@ describe('background-job mirror', () => { const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({ id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over, }) - const tasksFrame = (sessionId: SessionId, jobs: unknown[]) => - ({ rpcId: 't' as never, payload: { type: 'session/jobs', sessionId, jobs } as never }) + const tasksFrame = ( + sessionId: SessionId, + jobs: unknown[], + ): Extract => ({ + type: 'jobs', sessionId, jobs: jobs as never, + }) it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleMuxEnvelope(tasksFrame(S1, [view()])) - manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) + const manager = makeManager() + manager.handleControlFrame(tasksFrame(S1, [view()])) + manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) const first = manager.getListSnapshot().jobsBySession expect(first[S1]).toEqual([view()]) expect(first[S2]?.[0]?.label).toBe('other') // Last-wins: the newer whole set replaces, it does not merge. - manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })])) + manager.handleControlFrame(tasksFrame(S1, [view({ status: 'completed' })])) expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })]) }) it('stores an emptied set as an absent key so absence and [] read alike', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + const manager = makeManager() + manager.handleControlFrame(tasksFrame(S1, [view()])) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true) - manager.handleMuxEnvelope(tasksFrame(S1, [])) + manager.handleControlFrame(tasksFrame(S1, [])) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) }) - it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleMuxEnvelope(tasksFrame(S1, [view()])) - manager.handleMuxEnvelope({ - rpcId: 's' as never, - payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 }, + it('clears the mirror when the next control baseline has no jobs', () => { + const manager = makeManager() + manager.handleControlFrame(tasksFrame(S1, [view()])) + manager.handleControlFrame({ + type: 'baseline', + value: { queues: {}, jobs: {}, approvals: [], questions: [], projections: {} }, }) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) }) it('drops the rows when the session is removed, whichever stream lands first', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) - manager.handleMuxEnvelope(tasksFrame(S1, [view()])) - manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + const manager = makeManager() + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleControlFrame(tasksFrame(S1, [view()])) + manager.handleSessionRemoved(S1) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) }) it('notifies list subscribers so an open header re-renders without a poll', async () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) + const manager = makeManager() const seen = vi.fn() manager.subscribe(seen) - manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + manager.handleControlFrame(tasksFrame(S1, [view()])) // The notifier batches on a microtask; the frame itself is already applied. await Promise.resolve() expect(seen).toHaveBeenCalled() diff --git a/packages/client/runtime/tests/projection-store.client.spec.ts b/packages/client/runtime/tests/projection-store.client.spec.ts index 5ef2c41194..d80f41bb4f 100644 --- a/packages/client/runtime/tests/projection-store.client.spec.ts +++ b/packages/client/runtime/tests/projection-store.client.spec.ts @@ -4,7 +4,7 @@ * higher-seq-wins rule on both paths (a stale baseline cannot overwrite a * newer push frame; a replayed frame cannot regress), capability absence as * undefined, generation truncation, and the Session/manager wiring (tail-page - * seeding, session/projection frame routing pre- and post-instantiation, the + * seeding, control-stream projection routing pre- and post-instantiation, the * list rows' title projection). */ import { describe, expect, it } from 'vitest' @@ -103,7 +103,7 @@ describe('ProjectionValueStore semantics', () => { describe('Session tail-page seeding', () => { it('seeds the store from a history response carrying a projections block', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote()) + const session = new Session(SID, api, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } }, @@ -114,7 +114,7 @@ describe('Session tail-page seeding', () => { it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote()) + const session = new Session(SID, api, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } }, @@ -127,7 +127,7 @@ describe('Session tail-page seeding', () => { it('treats a blockless response as no reset: pushed values survive', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote()) + const session = new Session(SID, api, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) await session.open() session.projections.apply('test/marks', { marks: ['pushed'] }, 9) @@ -139,41 +139,41 @@ describe('Session tail-page seeding', () => { describe('manager frame routing', () => { const sid = (s: string): SessionId => s as SessionId - it('lands session/projection frames before instantiation and the Session adopts the same store', async () => { + it('lands projection frames before instantiation and the Session adopts the same store', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - manager.handleMuxEnvelope({ - rpcId: 'p1' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never, + const manager = new SessionManager(api, fakeRemote(api)) + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7, }) const session = manager.get(sid('s1')) expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] }) // Frames after instantiation land in the same store. - manager.handleMuxEnvelope({ - rpcId: 'p2' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9 } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9, }) expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] }) }) - it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => { + it('projects the title key into list rows and truncates phantom rows on the control baseline', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) await manager.refreshList() - manager.handleMuxEnvelope({ - rpcId: 't1' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4 } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4, }) await Promise.resolve() expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title') // The durable baseline says the host only knows up to seq 2: the row rode // lost state and must drop (the un-flushed title precedent). - manager.handleMuxEnvelope({ - rpcId: 'sub' as never, - payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 2 } as never, + manager.handleControlFrame({ + type: 'baseline', + value: { + queues: {}, jobs: {}, approvals: [], questions: [], + projections: { [sid('s1')]: { asOfSeq: 2, values: {} } }, + }, }) await Promise.resolve() expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() @@ -181,7 +181,7 @@ describe('manager frame routing', () => { it('projects every retained value into list rows with stable snapshot identity', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, @@ -196,12 +196,9 @@ describe('manager frame routing', () => { expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } }) expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline) - manager.handleMuxEnvelope({ - rpcId: 'p2' as never, - payload: { - type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', - value: { marks: ['live'] }, seq: 3, - } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', + value: { marks: ['live'] }, seq: 3, }) await Promise.resolve() expect(manager.getListSnapshot().items[0]?.projectionValues) @@ -211,19 +208,15 @@ describe('manager frame routing', () => { it('drops the projection store with the removed session', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) await manager.refreshList() - manager.handleMuxEnvelope({ - rpcId: 't1' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4 } as never, - }) - manager.handleHostEnvelope({ - rpcId: 'rm' as never, - payload: { type: 'host/session-removed', sessionId: sid('s1') } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4, }) + manager.handleSessionRemoved(sid('s1')) expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined() }) }) diff --git a/packages/client/runtime/tests/queue-store.client.spec.ts b/packages/client/runtime/tests/queue-store.client.spec.ts index da109e7da7..68394f4454 100644 --- a/packages/client/runtime/tests/queue-store.client.spec.ts +++ b/packages/client/runtime/tests/queue-store.client.spec.ts @@ -3,11 +3,15 @@ * change, reconnect re-baselining, pre-instantiation buffering, editable-text * projection, and snapshot reference stability. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { + SessionControlFrame, + SessionInteractionId, +} from '@deepseek-ai/dsh-api-session-controller/types' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, fakeRemote } from './fake-api.client.ts' @@ -26,29 +30,43 @@ interface QueueFixture { } /** Build one authoritative queue snapshot. */ -function queueFrame(items: QueueFixture[]): MuxFrame { +function queueFrame(items: QueueFixture[]): Extract { return { - type: 'session/queue', + type: 'queue', sessionId: SID, items: items.map(item => ({ id: iid(item.id), placement: item.placement ?? 'queued', - message: item.message ?? createUserMessage({ + message: (item.message ?? createUserMessage({ content: item.content ?? text(item.body), source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never, - }), + })) as never, })), } } function makeSession(): Session { - return new Session(SID, new FakeApiClient(), fakeRemote()) + return makeBench().session +} + +function makeBench(): { api: FakeApiClient; session: Session } { + const api = new FakeApiClient() + return { api, session: new Session(SID, api, fakeRemote(api)) } +} + +function makeManager(): SessionManager { + const api = new FakeApiClient() + return new SessionManager(api, fakeRemote(api)) +} + +function interactionId(value: string): SessionInteractionId { + return value as SessionInteractionId } describe('queue snapshot intake', () => { it('projects stable ids, flat previews, and complete text', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-1'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 'q-1', body: '第一条 排队\n消息' }, ])) const queue = session.getSnapshot().queue @@ -64,7 +82,7 @@ describe('queue snapshot intake', () => { it('marks mixed-content messages non-editable while retaining their preview', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-2'), queueFrame([{ + session.handleControlFrame(queueFrame([{ id: 'q-image', body: '', content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], @@ -83,7 +101,7 @@ describe('queue snapshot intake', () => { it('caps previews at 200 code points and preserves the full editable text', () => { const session = makeSession() const body = '长'.repeat(201) - session.handleMuxEnvelope(rid('env-3'), queueFrame([{ id: 'q-cap', body }])) + session.handleControlFrame(queueFrame([{ id: 'q-cap', body }])) const row = session.getSnapshot().queue[0] expect(Array.from(row?.preview ?? '')).toHaveLength(201) expect(row?.preview.endsWith('…')).toBe(true) @@ -92,11 +110,11 @@ describe('queue snapshot intake', () => { it('replaces content, order, and membership from each authoritative frame', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-4'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 'q-1', body: 'one' }, { id: 'q-2', body: 'two' }, ])) - session.handleMuxEnvelope(rid('env-5'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 'q-2', body: 'two edited' }, ])) const queue = session.getSnapshot().queue @@ -108,13 +126,13 @@ describe('queue snapshot intake', () => { preview: 'two edited', text: 'two edited', }, ]) - session.handleMuxEnvelope(rid('env-6'), queueFrame([])) + session.handleControlFrame(queueFrame([])) expect(session.getSnapshot().queue).toEqual([]) }) it('keeps the queue array reference stable across unrelated snapshot swaps', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-7'), queueFrame([{ id: 'q-stable', body: '稳定' }])) + session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }])) const before = session.getSnapshot().queue session.handleAgentError('unrelated') expect(session.getSnapshot().queue).toBe(before) @@ -122,7 +140,7 @@ describe('queue snapshot intake', () => { it('retains steering placement and complete content in the same authoritative snapshot', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-steering'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 'q-next', body: 'later' }, { id: 's-now', body: 'interrupt now', placement: 'steering' }, ])) @@ -136,13 +154,13 @@ describe('queue snapshot intake', () => { }) it('hands off exactly one current occurrence when live steering becomes durable', async () => { - const session = makeSession() + const { api, session } = makeBench() await session.open() const message = createUserMessage({ content: text('same message'), source: { kind: 'user' }, }) - session.handleMuxEnvelope(rid('env-same-id'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 's-first', body: '', placement: 'steering', message }, { id: 's-second', body: '', placement: 'steering', message }, ])) @@ -154,52 +172,53 @@ describe('queue snapshot intake', () => { data: message, } as SessionEvent - session.handleMuxEnvelope(rid('env-durable'), { - type: 'session/event', sessionId: SID, event: durable, + await api.pushFollow(SID, { type: 'event', event: durable as never }) + await vi.waitFor(() => { + expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) }) - expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) - session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 's-later', body: '', placement: 'steering', message }, ])) - session.handleMuxEnvelope(rid('env-replayed-durable'), { - type: 'session/event', sessionId: SID, event: durable, + await api.pushFollow(SID, { type: 'event', event: durable as never }) + await vi.waitFor(() => { + expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later']) }) - expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later']) }) it('hands off live steering when the agent claims it as a user message', async () => { - const session = makeSession() + const { api, session } = makeBench() await session.open() const message = createUserMessage({ content: text('claimed steering'), source: { kind: 'user' }, }) - session.handleMuxEnvelope(rid('env-claimed'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 's-claimed', body: '', placement: 'steering', message }, ])) - session.handleMuxEnvelope(rid('env-user-message'), { - type: 'session/event', - sessionId: SID, + await api.pushFollow(SID, { + type: 'event', event: { seq: 0, time: 1_700_000_000_000, type: 'user/message', surfaceOp: 'append', data: message, - }, + } as never, }) - expect(session.getSnapshot().queue).toEqual([]) + await vi.waitFor(() => { + expect(session.getSnapshot().queue).toEqual([]) + }) }) }) describe('queue operation transport', () => { it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote()) - session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }])) + const session = new Session(SID, api, fakeRemote(api)) + session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }])) const before = session.getSnapshot().queue await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') })) @@ -223,26 +242,26 @@ describe('queue operation transport', () => { }) describe('queue reconnect semantics', () => { - it('session/subscribed clears stale state before the fresh snapshot lands', () => { + it('a control baseline clears stale state before a fresh update lands', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-old', body: '旧连接' }])) - session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 }) + session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }])) + session.replaceControl([], []) expect(session.getSnapshot().queue).toEqual([]) - session.handleMuxEnvelope(rid('e3'), queueFrame([{ id: 'q-new', body: '新基线' }])) + session.handleControlFrame(queueFrame([{ id: 'q-new', body: '新基线' }])) expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) it('resync does not clear a baseline that raced ahead of the host connection signal', async () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 }) - session.handleMuxEnvelope(rid('e2'), queueFrame([{ id: 'q-fresh', body: '新基线' }])) + await session.open() + session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }])) await session.resync() expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh']) }) it('running-status changes never guess at queue retirement', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-live', body: '保留' }])) + session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }])) session.handleRunning(true) session.handleRunning(false) expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live']) @@ -251,24 +270,31 @@ describe('queue reconnect semantics', () => { describe('manager buffering of queue snapshots', () => { it('replays only the latest snapshot for an uninstantiated session', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) }) - manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) }) + const manager = makeManager() + manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }])) + manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }])) expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) - it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) }) - manager.handleMuxEnvelope({ - rpcId: rid('g1b'), - payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' }, + it('a control baseline replaces the prior queue and restores answerable interactions', () => { + const manager = makeManager() + manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }])) + const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items + manager.handleControlFrame({ + type: 'baseline', + value: { + queues: { [SID]: nextQueue }, + jobs: {}, + approvals: [{ + interactionId: interactionId('approval-1'), + sessionId: SID, + approvalId: 'ap-1' as never, + toolName: 'bash', + }], + questions: [], + projections: {}, + }, }) - manager.handleMuxEnvelope({ - rpcId: rid('g2a'), - payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 }, - }) - manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queueFrame([{ id: 'q-g2', body: '第二代' }]) }) const snapshot = manager.get(SID).getSnapshot() expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2']) expect(snapshot.pending.map(pending => pending.kind)).toEqual(['approval']) diff --git a/packages/client/runtime/tests/session.client.spec.ts b/packages/client/runtime/tests/session.client.spec.ts index 19e80dfd5b..d8062b84d3 100644 --- a/packages/client/runtime/tests/session.client.spec.ts +++ b/packages/client/runtime/tests/session.client.spec.ts @@ -7,9 +7,14 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-commands/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { + SessionInteractionId, + SessionToolView, +} from '@deepseek-ai/dsh-api-session-controller/types' import { Session } from '../src/client/sessions/session.ts' import type { ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, @@ -159,7 +164,23 @@ const TEST_CONVERSATION: ConversationRuntime = { } function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, api, fakeRemote(), { conversation: TEST_CONVERSATION }) } + return { api, session: new Session(SID, api, fakeRemote(api), { conversation: TEST_CONVERSATION }) } +} + +function interactionId(value: string): SessionInteractionId { + return value as SessionInteractionId +} + +function follow( + api: FakeApiClient, + event: SessionEvent, + view?: SessionToolView, +): Promise { + return api.pushFollow(SID, { + type: 'event', + event: event as never, + ...(view === undefined ? {} : { view }), + }) } function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] { @@ -234,14 +255,16 @@ describe('open', () => { const opening = session.open() // Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15). const page = plainTurn(10, 0, '早', '安') - session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) }) - session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') }) + const deliveries = [ + follow(api, ev.turnStart(15, 1)), + follow(api, ev.user(16, '插进来的')), + ] gate.resolve(ok({ events: entries(page) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) - await opening + await Promise.all([opening, ...deliveries]) const seqs = session.getSnapshot().nodes.map(n => n.seq) // Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once. expect(seqs).toEqual([11, 13, 16]) @@ -258,33 +281,32 @@ describe('live event path', () => { } it('drops replayed frames at or below the window tail', async () => { - const { session } = await opened() + const { api, session } = await opened() const before = session.getSnapshot() - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(3, '重放') }) - await Promise.resolve() + await follow(api, ev.user(3, '重放')) expect(session.getSnapshot().nodes).toEqual(before.nodes) }) it('keeps the authoritative host blank bit across unrelated log events', async () => { - const { session } = await opened([]) + const { api, session } = await opened([]) session.handleBlank(true) expect(session.getSnapshot().composerPhase).toBe('blank') - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')) - feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')) + await Promise.all([ + follow(api, ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')), + follow(api, ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')), + ]) const snapshot = session.getSnapshot() expect(chatSeqs(snapshot)).toEqual([0, 1]) expect(snapshot.composerPhase).toBe('blank') }) it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => { - const { session } = await opened([]) + const { api, session } = await opened([]) session.handleBlank(true) - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) - } - feed(ev.commandRun(0, 'cmd-goal', 'goal', ' ')) - feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.')) + await Promise.all([ + follow(api, ev.commandRun(0, 'cmd-goal', 'goal', ' ')), + follow(api, ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.')), + ]) expect(session.getSnapshot()).toMatchObject({ blank: true, @@ -301,26 +323,26 @@ describe('live event path', () => { frames.push(callback) return frames.length }) - const { session } = await opened() + const { api, session } = await opened() const published: number[][] = [] session.subscribe(() => { published.push(chatSeqs(session.getSnapshot())) }) - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) - } - - feed(ev.chunkStart(6, 1)) - feed(ev.chunkText(7, 1, '累')) - feed(ev.chunkText(8, 1, '计')) + await Promise.all([ + follow(api, ev.chunkStart(6, 1)), + follow(api, ev.chunkText(7, 1, '累')), + follow(api, ev.chunkText(8, 1, '计')), + ]) expect(published).toEqual([]) expect(frames).toHaveLength(1) frames.shift()!(0) expect(published).toEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8]]) - feed(ev.chunkText(9, 1, '完成')) - feed(ev.assistant(10, 1, '累计完成')) + await Promise.all([ + follow(api, ev.chunkText(9, 1, '完成')), + follow(api, ev.assistant(10, 1, '累计完成')), + ]) await Promise.resolve() expect(published).toEqual([ [0, 1, 2, 3, 4, 5, 6, 7, 8], @@ -343,17 +365,12 @@ describe('live event path', () => { entries: () => [testViewDefinition()], } as unknown as ConversationRuntime['views'], } - const session = new Session(SID, api, fakeRemote(), { conversation }) + const session = new Session(SID, api, fakeRemote(api), { conversation }) await session.open() const snapshots: ConversationSnapshot[] = [] session.subscribe(() => { snapshots.push(session.getSnapshot()) }) - session.handleMuxEnvelope('timeline' as never, { - type: 'session/event', - sessionId: SID, - event: ev.turnStart(0, 1), - }) - await Promise.resolve() + await follow(api, ev.turnStart(0, 1)) expect(snapshots).toHaveLength(1) expect(snapshots[0]?.chat.timeline.turns.get(1)?.status).toBe('open') @@ -364,13 +381,14 @@ describe('live event path', () => { const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] api.onHistory = () => histResponse(repaired) // seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires. - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(9, 1, 'd') }) + await follow(api, ev.assistant(9, 1, 'd')) await vi.waitFor(() => { expect(api.callsOf('session.history').length).toBe(2) }) - await Promise.resolve() - const seqs = session.getSnapshot().nodes.map(n => n.seq) - expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9 + await vi.waitFor(() => { + const seqs = session.getSnapshot().nodes.map(n => n.seq) + expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9 + }) }) }) @@ -448,7 +466,7 @@ describe('paging', () => { describe('prompt and cancel errors', () => { it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote(), { + const session = new Session(SID, api, fakeRemote(api), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) @@ -487,7 +505,7 @@ describe('prompt and cancel errors', () => { api.onSubagentInterrupt = () => Promise.resolve(err({ code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, }) as never) - const session = new Session(SID, api, fakeRemote(), { + const session = new Session(SID, api, fakeRemote(api), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) @@ -501,7 +519,7 @@ describe('prompt and cancel errors', () => { it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote(), { + const session = new Session(SID, api, fakeRemote(api), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, }) await session.open() @@ -594,7 +612,9 @@ describe('rename', () => { it('returns the business error untouched and folds a transport throw to internal', async () => { const { api, session } = makeSession() - api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'empty', details: { sessionId: SID } })) + api.onRename = () => Promise.resolve(err({ + code: 'title-invalid', message: 'empty', details: { sessionId: SID }, + } as never)) const rejected = await session.rename(' ') expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } }) expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined() @@ -607,17 +627,32 @@ describe('rename', () => { describe('pending interactions', () => { it('adds approval/question on requested and removes them on resolved', async () => { const { session } = makeSession() - session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) - session.handleMuxEnvelope('rq' as never, { type: 'question/requested', sessionId: SID, questions: [] }) + session.handleControlFrame({ + type: 'approval/requested', interactionId: interactionId('ra'), + sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm', + }) + session.handleControlFrame({ + type: 'question/requested', interactionId: interactionId('rq'), + sessionId: SID, questions: [], + }) expect(session.getSnapshot().pending.map(p => p.kind).sort()).toEqual(['approval', 'question']) - session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap1' as never, outcome: 'approved' as never }) - session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' }) + session.handleControlFrame({ + type: 'approval/resolved', interactionId: interactionId('ra'), + sessionId: SID, approvalId: 'ap1' as never, outcome: 'allowed-once', + }) + session.handleControlFrame({ + type: 'question/resolved', interactionId: interactionId('rq'), + sessionId: SID, outcome: 'answered', + }) expect(session.getSnapshot().pending).toEqual([]) }) - it('mints waits whose respond() backfills the requested rpcId into the client-response envelope', async () => { + it('mints waits whose respond() addresses the stable interaction id', async () => { const { api, session } = makeSession() - session.handleMuxEnvelope('rq-answer' as never, { type: 'question/requested', sessionId: SID, questions: [] }) + session.handleControlFrame({ + type: 'question/requested', interactionId: interactionId('rq-answer'), + sessionId: SID, questions: [], + }) const wait = session.getSnapshot().pending[0]! expect(wait).toMatchObject({ kind: 'question', key: 'q:rq-answer', sessionId: SID, payload: { questions: [] } }) const receipt = await wait.respond({ @@ -625,8 +660,8 @@ describe('pending interactions', () => { value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } }, }) expect(receipt).toEqual({ accepted: true }) - expect(api.callsOf('respond')).toEqual([{ - type: 'client-response', rpcId: 'rq-answer', + expect(api.callsOf('session.respond')).toEqual([{ + interactionId: 'rq-answer', result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } }, @@ -636,13 +671,19 @@ describe('pending interactions', () => { it('settles the wait on the authoritative resolved frame: respond() then throws synchronously', async () => { const { api, session } = makeSession() - session.handleMuxEnvelope('rq1' as never, { type: 'question/requested', sessionId: SID, questions: [] }) + session.handleControlFrame({ + type: 'question/requested', interactionId: interactionId('rq1'), + sessionId: SID, questions: [], + }) const wait = session.getSnapshot().pending[0]! - session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq1' as never, outcome: 'answered' }) + session.handleControlFrame({ + type: 'question/resolved', interactionId: interactionId('rq1'), + sessionId: SID, outcome: 'answered', + }) expect(session.getSnapshot().pending).toEqual([]) expect(() => wait.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } })) .toThrow('already settled') - expect(api.callsOf('respond')).toEqual([]) + expect(api.callsOf('session.respond')).toEqual([]) }) }) @@ -711,7 +752,7 @@ describe('remaining branches', () => { expect(notified).toBe(seen) }) - it('subscribed baseline past the window tail triggers the second stitch pull in doOpen', async () => { + it('an opening cursor past the window tail triggers the second stitch pull in doOpen', async () => { const { api, session } = makeSession() const full = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] let call = 0 @@ -719,14 +760,13 @@ describe('remaining branches', () => { call++ return histResponse(call === 1 ? plainTurn(0, 0, 'a', 'b') : full) } - // Baseline arrives before open: lastSeq 11 > first page tail 5 → doOpen repulls once. - session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 }) + api.followCursor = 11 await session.open() expect(call).toBe(2) expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([1, 3, 7, 9]) }) - it('a failed second stitch pull keeps the first window and still opens', async () => { + it('a failed opening repair rejects the incomplete window and reports the unresolved gap', async () => { const { api, session } = makeSession() let call = 0 api.onHistory = () => { @@ -735,30 +775,41 @@ describe('remaining branches', () => { ? histResponse(plainTurn(0, 0, 'a', 'b')) : Promise.resolve(err({ code: 'internal', message: 'stitch pull down', details: {} })) } - session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 }) + api.followCursor = 11 await session.open() expect(call).toBe(2) const snapshot = session.getSnapshot() - expect(snapshot.openState).toBe('open') // stitch-pull failure is not an open failure - expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3]) // first window kept + expect(snapshot.openState).toBe('error') + expect(snapshot.openError).toMatchObject({ code: 'internal', message: 'stitch pull down' }) + expect(snapshot.nodes).toEqual([]) }) it('approval frame with callId/reason keeps the optional fields; duplicate resolved is a no-op', () => { const { session } = makeSession() - session.handleMuxEnvelope('ra' as never, { - type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险', + session.handleControlFrame({ + type: 'approval/requested', interactionId: interactionId('ra'), + sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', + callId: 'c1' as never, reason: '危险', }) expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', payload: { callId: 'c1', reason: '危险' } }) - session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never }) - session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never }) - session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' }) + session.handleControlFrame({ + type: 'approval/resolved', interactionId: interactionId('ra'), + sessionId: SID, approvalId: 'ap2' as never, outcome: 'allowed-once', + }) + session.handleControlFrame({ + type: 'approval/resolved', interactionId: interactionId('ra'), + sessionId: SID, approvalId: 'ap2' as never, outcome: 'allowed-once', + }) + session.handleControlFrame({ + type: 'question/resolved', interactionId: interactionId('never-was'), + sessionId: SID, outcome: 'cancelled', + }) expect(session.getSnapshot().pending).toEqual([]) }) - it('ignores unknown mux frame types and repeated running flips (documented defaults)', () => { + it('deduplicates repeated running flips and records removal', () => { const { session } = makeSession() const before = session.getSnapshot() - session.handleMuxEnvelope('rz' as never, { type: 'future/frame' } as never) session.handleRunning(false) // already false: dedup branch expect(session.getSnapshot()).toBe(before) session.handleRemoved() @@ -767,15 +818,31 @@ describe('remaining branches', () => { it('drops live events while cold/error (no window upkeep)', async () => { const { api, session } = makeSession() - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(0, '冷态帧') }) + await follow(api, ev.user(0, '冷态帧')) expect(session.getSnapshot().nodes).toEqual([]) api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} })) await session.open() - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(0, '错态帧') }) + await follow(api, ev.user(0, '错态帧')) expect(session.getSnapshot().nodes).toEqual([]) }) - it('repairGap failure logs and clears stitching; concurrent gaps coalesce into one repair', async () => { + it('preserves a Host-reported failure that terminates the live source', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const failure = { + code: 'session-not-found', + message: 'session disappeared', + details: { sessionId: SID }, + } + + api.failStreams(new RemoteStreamError(failure.code, failure.message, failure.details)) + await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) + + expect(session.getSnapshot().openError).toEqual(failure) + }) + + it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) await session.open() @@ -785,18 +852,16 @@ describe('remaining branches', () => { repairs++ return gate.promise } - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞一') }) - session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(10, '洞二') }) // stitching: detours, no second repair - expect(repairs).toBe(1) - gate.reject(new Error('repair wire down')) - await vi.waitFor(() => { expect(errorSpy).toHaveBeenCalled() }) - // Window unchanged; a later successful repull still lands the buffered frames. - expect(session.getSnapshot().nodes).toHaveLength(2) - } finally { - errorSpy.mockRestore() - } + const deliveries = Promise.all([ + follow(api, ev.user(9, '洞一')), + follow(api, ev.user(10, '洞二')), + ]) + await vi.waitFor(() => { expect(repairs).toBe(1) }) + gate.reject(new Error('repair wire down')) + await deliveries + await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) + expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'repair wire down' }) + expect(session.getSnapshot().nodes).toHaveLength(2) }) it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => { @@ -837,7 +902,7 @@ describe('remaining branches', () => { if (call === 2) return secondPull.promise // gap-stitch pull: held return histResponse(plainTurn(6, 1, 'c', 'd')) } - session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 }) + api.followCursor = 11 const opening = session.open() // triggers the second pull, which parks await vi.waitFor(() => { expect(call).toBe(2) }) const resynced = session.resync() @@ -856,7 +921,8 @@ describe('remaining branches', () => { await session.open() const repairPull = deferred>>() api.onHistory = () => repairPull.promise - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap + const delivery = follow(api, ev.user(9, '洞')) + await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) }) api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd')) const resynced = session.resync() // bumps the generation repairPull.resolve(ok({ @@ -864,7 +930,7 @@ describe('remaining branches', () => { hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'stale' }, })) // repair result: stale, dropped - await resynced + await Promise.all([delivery, resynced]) expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) }) @@ -882,7 +948,7 @@ describe('remaining branches', () => { expect(() => { session.dispose() }).not.toThrow() }) - it('carries history-entry and mux-frame views into the business-neutral Event input', async () => { + it('carries history-entry and follow-frame views into the business-neutral Event input', async () => { const { api, session } = makeSession() const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } } api.onHistory = () => Promise.resolve(ok({ @@ -899,17 +965,19 @@ describe('remaining branches', () => { callView, { for: 'result', view: { card: 'generic', title: '历史果' } }, ]) - session.handleMuxEnvelope('rv1' as never, { - type: 'session/event', sessionId: SID, event: ev.toolCall(8, 2, 'l1', 'write', '{}'), - view: { for: 'call', view: { card: 'generic', title: '直播卡' } }, - } as never) + await follow( + api, + ev.toolCall(8, 2, 'l1', 'write', '{}'), + { for: 'call', view: { card: 'generic', title: '直播卡' } }, + ) expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({ for: 'call', view: { card: 'generic', title: '直播卡' }, }) - session.handleMuxEnvelope('rv2' as never, { - type: 'session/event', sessionId: SID, event: ev.toolResult(9, 2, 'l1', 'ok'), - view: { for: 'result', view: { card: 'generic', title: '直播果' } }, - } as never) + await follow( + api, + ev.toolResult(9, 2, 'l1', 'ok'), + { for: 'result', view: { card: 'generic', title: '直播果' } }, + ) expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({ for: 'result', view: { card: 'generic', title: '直播果' }, }) @@ -917,16 +985,19 @@ describe('remaining branches', () => { }) describe('resync', () => { - it('rebuilds the window and clears pending; cold instances no-op', async () => { + it('rebuilds the window without clearing control state; cold instances no-op', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) await session.open() - session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) + session.handleControlFrame({ + type: 'approval/requested', interactionId: interactionId('ra'), + sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm', + }) api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) await session.resync() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('open') - expect(snapshot.pending).toEqual([]) // baseline replay re-sends still-pending frames + expect(snapshot.pending).toHaveLength(1) expect(snapshot.nodes).toHaveLength(4) const cold = makeSession() @@ -934,20 +1005,22 @@ describe('resync', () => { expect(cold.api.calls).toEqual([]) // never opened: no traffic }) - it('re-mints a replayed requested frame as a fresh wait with the same key (old reference superseded)', async () => { + it('re-mints a control-baseline interaction with the same key (old reference superseded)', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) await session.open() - session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] }) + const request = { + interactionId: interactionId('rq-replay'), sessionId: SID, questions: [], + } + session.handleControlFrame({ type: 'question/requested', ...request }) const before = session.getSnapshot().pending[0]! - await session.resync() - session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] }) + session.replaceControl([], [request]) const after = session.getSnapshot().pending[0]! expect(after).not.toBe(before) expect(after.key).toBe(before.key) // Superseded ≠ settled: an in-flight respond on the stale reference still reaches the host. await before.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } }) - expect(api.callsOf('respond')).toMatchObject([{ rpcId: 'rq-replay' }]) + expect(api.callsOf('session.respond')).toMatchObject([{ interactionId: 'rq-replay' }]) }) it('drops a stale in-flight open superseded by resync (generation guard)', async () => { @@ -977,7 +1050,7 @@ describe('reference stability (the memo contract)', () => { const secondKey = before.chat.order[1]! const first = before.chat.nodes.get(firstKey) const second = before.chat.nodes.get(secondKey) - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(6, '追加') }) + await follow(api, ev.user(6, '追加')) const after = session.getSnapshot() expect(after).not.toBe(before) // top-level swap on change expect(after.chat.nodes.get(firstKey)).toBe(first) @@ -991,26 +1064,32 @@ describe('reference stability (the memo contract)', () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, '底', '座')) await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.stepStart(7, 1)) - feed(ev.toolCall(8, 1, 'c1', 'echo', '{}')) - session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) + await Promise.all([ + follow(api, ev.turnStart(6, 1)), + follow(api, ev.stepStart(7, 1)), + follow(api, ev.toolCall(8, 1, 'c1', 'echo', '{}')), + ]) + session.handleControlFrame({ + type: 'approval/requested', interactionId: interactionId('ra'), + sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm', + }) const before = session.getSnapshot() const settledKey = before.chat.order[0]! const settledNode = before.chat.nodes.get(settledKey) - feed(ev.chunkStart(9, 1)) - feed(ev.chunkText(10, 1, '与工具无关的流式')) + await Promise.all([ + follow(api, ev.chunkStart(9, 1)), + follow(api, ev.chunkText(10, 1, '与工具无关的流式')), + ]) const after = session.getSnapshot() expect(after).not.toBe(before) expect(after.runningCalls).toBe(before.runningCalls) expect(after.pending).toBe(before.pending) expect(after.chat.nodes.get(settledKey)).toBe(settledNode) - feed(ev.toolResult(11, 1, 'c1', 'ECHO')) + await follow(api, ev.toolResult(11, 1, 'c1', 'ECHO')) const resolved = session.getSnapshot() expect(resolved.pending).toBe(after.pending) expect(resolved.chat.nodes.get(settledKey)).toBe(settledNode) - feed(ev.assistant(12, 1, '完成')) + await follow(api, ev.assistant(12, 1, '完成')) expect(session.getSnapshot()).not.toBe(resolved) }) }) diff --git a/packages/client/runtime/tests/sessions-service.client.spec.ts b/packages/client/runtime/tests/sessions-service.client.spec.ts index b135b56ebe..ab2638847f 100644 --- a/packages/client/runtime/tests/sessions-service.client.spec.ts +++ b/packages/client/runtime/tests/sessions-service.client.spec.ts @@ -23,7 +23,7 @@ interface Bench { function bench(): Bench { const ctx = new Context() const api = new FakeApiClient() - const svc = new SessionRuntime(ctx, api, fakeRemote()) + const svc = new SessionRuntime(ctx, api, fakeRemote(api)) return { ctx, api, svc } } @@ -55,9 +55,8 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise { describe('list store projection', () => { it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => { const b = bench() - b.svc.handleMuxEnvelope({ - rpcId: 'title' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2 } as never, + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2, }) await feedList(b, [ { id: 's1', cwd: '/home/u/proj-a/' }, @@ -90,7 +89,9 @@ describe('list store projection', () => { it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', blank: true, sessionId: sid('s2') } as never }) + b.svc.handleSessionAdded({ + sessionId: sid('s2'), updatedAt: 2, running: false, blank: true, + }) await Promise.resolve() expect(b.svc.list.getSnapshot().ids).toContain('s2') }) @@ -528,9 +529,8 @@ describe('fork', () => { ['计划 (9)', '计划 (10)'], ])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => { const b = bench() - b.svc.handleMuxEnvelope({ - rpcId: 'source-title' as never, - payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2 } as never, + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2, }) await feedList(b, [{ id: 'source', cwd: '/work' }]) b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) @@ -578,15 +578,14 @@ describe('fork', () => { it('rejects when child rename fails while keeping the published child addressable', async () => { const b = bench() - b.svc.handleMuxEnvelope({ - rpcId: 'source-title' as never, - payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2 } as never, + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2, }) await feedList(b, [{ id: 'source' }]) b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) b.api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') }, - })) + } as never)) await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })) .rejects.toThrow('fork child rename failed: title-invalid: rejected') @@ -599,18 +598,14 @@ describe('scope lifecycle rides the list mirror (entity parity: no client-side p const b = bench() await feedList(b, []) expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions - b.svc.handleHostEnvelope({ - rpcId: 'add' as never, - payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never, + b.svc.handleSessionAdded({ + sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a', }) await Promise.resolve() const scoped = b.svc.scope(sid('s-new')) expect(scoped).toBeDefined() expect(scopeOf(scoped as Context)).toBe('s-new') - b.svc.handleHostEnvelope({ - rpcId: 'rm' as never, - payload: { type: 'host/session-removed', sessionId: sid('s-new') }, - }) + b.svc.handleSessionRemoved(sid('s-new')) await Promise.resolve() expect(b.svc.scope(sid('s-new'))).toBeUndefined() }) @@ -621,10 +616,7 @@ describe('blank mirror', () => { const b = bench() await feedList(b, [{ id: 's1', blank: true }]) expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true }) - b.svc.handleHostEnvelope({ - rpcId: 'st' as never, - payload: { type: 'host/session-status', sessionId: sid('s1'), running: true }, - }) + b.svc.handleSessionStatus(sid('s1'), true) await Promise.resolve() expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true }) // The instantiated Session mirrors the same flip. @@ -669,9 +661,8 @@ describe('blank mirror', () => { it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async () => { const b = bench() await feedList(b, []) - b.svc.handleHostEnvelope({ - rpcId: 'add' as never, - payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never, + b.svc.handleSessionAdded({ + sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a', }) await Promise.resolve() expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: true }) diff --git a/packages/client/runtime/tests/wire-events.client.spec.ts b/packages/client/runtime/tests/wire-events.client.spec.ts deleted file mode 100644 index fbc4204b0d..0000000000 --- a/packages/client/runtime/tests/wire-events.client.spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to - * the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is - * api-gateway's own coverage); each established connection generation emits - * `connection/reset` for generation-scoped cache invalidation. - */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' -import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client' -import TypertRegistry from '@deepseek-ai/dsh-typert-registry' -// Type-only: the api-remotes facade carries both the allowlist's selection seat -// and the owner packages' `./types` declarations, which together give `$on` its -// key face and per-event listener signatures. -import type {} from '@deepseek-ai/dsh-api-remotes/client' -import * as RuntimeClient from '../src/client/index.ts' -import { FakeApiClient, fakeRemote } from './fake-api.client.ts' - -/** - * Compile-time face of `ctx.remote.$on`, asserted by type-checking this file - * rather than by running it: the allowlist narrows the key set, and each - * listener's parameters come from the owner package's own cordis `Events` - * declaration (so a brand cannot be flattened on the way to a consumer). - * @param ctx - any client Context carrying the Remote service. - */ -function forwardedEventContracts(ctx: Context): void { - ctx.remote.$on('settings/document-updated', (namespace, source) => { - // @ts-expect-error -- the brand survives the wire: a bare string is not a SettingsNamespace - const bare: typeof namespace = 'plain-string' - void bare; void namespace; void source - }) - ctx.remote.$on('credentials/reference-updated', () => {}) - ctx.remote.$on('commands/change', () => {}) - ctx.remote.$on('llm/adapters-updated', () => {}) - ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => { - void sessionId; void agentPreset - }) - // @ts-expect-error -- client-local event outside the allowlist - ctx.remote.$on('slots/changed', () => {}) - // @ts-expect-error -- declared host event the allowlist does not select - ctx.remote.$on('skills/change', () => {}) -} -void forwardedEventContracts - -interface Bench { - ctx: Context - sinks: ConnectionSinks | undefined - /** Every `$dispatch` the runtime made, as `[event, ...args]`. */ - dispatched: unknown[][] -} - -async function mount(): Promise { - const ctx = new Context() - await ctx.plugin(TypertRegistry) - const api = new FakeApiClient() - const bench: Bench = { ctx, sinks: undefined, dispatched: [] } - // Stands in for api-gateway's Remote service: this spec owns the carrier's - // handoff, not the fan-out behind it. - ctx.reflect.provide('remote', { - $dispatch: (event: string, args: readonly unknown[]) => { bench.dispatched.push([event, ...args]) }, - }) - const handle: ConnectionHandle = { - api, - isLoopback: true, - hostDescription: { - getSnapshot: () => undefined, - subscribe: () => () => {}, - }, - rpc: { - call: () => Promise.reject(new Error('unexpected generic RPC call')), - }, - start: (sinks) => { - bench.sinks = sinks - return { stop: () => {} } - }, - } - ctx.reflect.provide('connection', handle) - ctx.reflect.provide('remote.commands', fakeRemote().commands) - await ctx.plugin(RuntimeClient).await() - return bench -} - -describe('wire event bridge', () => { - it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => { - const bench = await mount() - const seen = bench.dispatched - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r1' as never, - payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, - }) - expect(seen).toEqual([['commands/change']]) - - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r2' as never, - payload: { type: 'host/session-status', sessionId: 's1' as never, running: true }, - }) - expect(seen).toEqual([['commands/change']]) - }) - - it('carries each forwarded event name with its own argument list, unfiltered', async () => { - const bench = await mount() - const seen = bench.dispatched - - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r3' as never, - payload: { type: 'host/remote-event', event: 'settings/document-updated', args: ['llm-pi-ai', 7] }, - }) - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r4' as never, - payload: { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] }, - }) - // The carrier does not second-guess the name: selecting what a consumer can - // receive is the allowlist's job, and dropping an unsubscribed name is the - // Remote service's. This plugin republishes whatever the frame carried. - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r5' as never, - payload: { type: 'host/remote-event', event: 'nobody/listening', args: ['ignored'] }, - }) - - expect(seen).toEqual([ - ['settings/document-updated', 'llm-pi-ai', 7], - ['credentials/reference-updated', 'OPENAI_API_KEY'], - ['nobody/listening', 'ignored'], - ]) - }) - - it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => { - const bench = await mount() - let resets = 0 - bench.ctx.on('connection/reset', () => { resets++ }) - const description = { version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true } - bench.sinks?.onConnected?.(description) - bench.sinks?.onConnected?.(description) // second generation after a reconnect - expect(resets).toBe(2) - }) -}) diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 835f8cf0b8..38adff21a7 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -80,9 +80,7 @@ async function bench() { const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - // The plugins inject `remote`; forwarded events reach them through the - // same `$dispatch` handoff the connection sink makes. - new TestRemote(ctx) + const remote = new TestRemote(ctx) const calls: string[] = [] ctx.provide('connection', { api: { @@ -120,7 +118,7 @@ async function bench() { }, } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault } + return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote } } function declareRoot(slots: SlotRegistry): () => void { @@ -255,18 +253,18 @@ describe('ui-agent-preset apply', () => { }) it('refreshes a showing surface when its namespace changes, and ignores others', async () => { - const { ctx, slots, calls } = await bench() + const { ctx, slots, calls, remote } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() await section.load() const before = calls.length - ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1]) + remote.emit('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(calls.length).toBe(before + 2) }) const afterRelevant = calls.length - ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1]) + remote.emit('settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() // Both surfaces re-read on their own namespace; an unrelated one moves @@ -289,12 +287,12 @@ describe('ui-agent-preset apply', () => { }) it('leaves the section alone until it has been opened once', async () => { - const { ctx, slots, calls } = await bench() + const { ctx, slots, calls, remote } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() const before = calls.length - ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1]) + remote.emit('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) }) // Only the General row reloads: a section nobody opened has nothing to @@ -325,7 +323,7 @@ describe('ui-agent-preset apply', () => { }) it('moves the chip when the default changes on the settings surface', async () => { - const { ctx, slots, moveDefault } = await bench() + const { ctx, slots, moveDefault, remote } = await bench() declareRoot(slots) const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) @@ -345,11 +343,11 @@ describe('ui-agent-preset apply', () => { // An unrelated namespace moves nothing: the chip re-reads on its own // setting, not on every settings write in the process. moveDefault() - ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1]) + remote.emit('settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard') - ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1]) + remote.emit('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal') }) @@ -357,7 +355,7 @@ describe('ui-agent-preset apply', () => { }) it('folds a remote preset commit into the shared session row', async () => { - const { ctx, slots } = await bench() + const { ctx, slots, remote } = await bench() declareRoot(slots) declareConversation(slots) ctx.provide('conversation', {} as never) @@ -369,7 +367,7 @@ describe('ui-agent-preset apply', () => { ctx.provide('workspaces', workspacesDouble() as never) await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() - ctx.remote.$dispatch('agent-preset/selected', ['s1', 'minimal']) + remote.emit('agent-preset/selected', ['s1', 'minimal']) expect(state.byId.s1.agentPreset).toBe('minimal') }) diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 1d3bc796f4..afaabef7f5 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' @@ -112,19 +113,7 @@ async function bench(opts: BenchOptions = {}) { ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) - const forwarded = new Map void>>() - ctx.provide('remote', { - commands: commandsRemote, - $on: (event: string, listener: (...args: never[]) => void) => { - const listeners = forwarded.get(event) ?? [] - listeners.push(listener) - forwarded.set(event, listeners) - return () => { forwarded.set(event, listeners.filter(entry => entry !== listener)) } - }, - $dispatch: (event: string, args: readonly unknown[]) => { - for (const listener of forwarded.get(event) ?? []) listener(...args as never[]) - }, - }) + const remote = Object.assign(new TestRemote(ctx), { commands: commandsRemote }) ctx.provide('remote.commands', commandsRemote) const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = [] ctx.on('command/executed', (sessionId, name, result) => { @@ -155,7 +144,7 @@ async function bench(opts: BenchOptions = {}) { const warm = async (session: ClientSessionContext) => { await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal }) } - return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices } + return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, remote } } function menuPick(source: InputTriggerSource, name: string, session: ClientSessionContext, end?: number) { @@ -760,7 +749,7 @@ describe('popupFor', () => { describe('directory invalidation events', () => { it('commands/change repulls in the background while the old snapshot serves', async () => { let round = 0 - const { ctx, source, warm } = await bench({ + const { source, warm, remote } = await bench({ commands: () => { round += 1 return Promise.resolve({ @@ -771,7 +760,7 @@ describe('directory invalidation events', () => { }, }) await warm(proj('s1')) - ctx.remote.$dispatch('commands/change', []) + remote.emit('commands/change', []) await new Promise(resolve => setTimeout(resolve, 0)) expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() @@ -779,7 +768,7 @@ describe('directory invalidation events', () => { it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => { const rounds = new Map() - const { ctx, source, warm } = await bench({ + const { source, warm, remote } = await bench({ commands: (payload) => { const round = (rounds.get(payload.sessionId) ?? 0) + 1 rounds.set(payload.sessionId, round) @@ -794,7 +783,7 @@ describe('directory invalidation events', () => { await warm(proj('s2')) // A preset switch changes which commands one session's agent resolves; // every other session keeps the catalog its own composition serves. - ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal']) + remote.emit('agent-preset/selected', [sid('s1'), 'minimal']) await new Promise(resolve => setTimeout(resolve, 0)) expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 307ac38482..020c31a25e 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -675,7 +675,7 @@ export type ApprovalWait = PendingWait<'approval'> /** * Approval domain face over the carrier (the ui-user-questions PendingQuestion * pattern): render identity and question material forwarded transparently; - * answer owns the wire encoding — the ApprovalResponsePayload value shape + * answer owns the Session Controller approval-response value * with the audit correlation the host reconciles — and turns a rejected * carrier receipt into a thrown error. Minted per carrier via useMemo. */ diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index b9ace8b863..72a015e996 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -223,7 +223,7 @@ export interface InputState { readonly occurrences: readonly Occurrence[] /** Live paste-match attempt (absent when no paste is matchable). */ readonly paste?: PasteAttemptState - /** Read-only transient inbox projection (`session/queue`, including pending steering). */ + /** Read-only transient inbox projection from Session control, including pending steering. */ readonly queue: readonly QueuedMessage[] } diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index b2072fb220..7c9321aa85 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -12,7 +12,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore, EMPTY_CONVERSATION_VIEWS, PendingWait, } from '@deepseek-ai/dsh-client-runtime/client' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionInteractionId } from '@deepseek-ai/dsh-api-remotes/client' import type { ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -1342,9 +1342,9 @@ describe('ChatView', () => { it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => { const h = makeHarness({ pending: [ - new PendingWait('approval', RpcId('r1'), SID, + new PendingWait('approval', 'r1' as SessionInteractionId, SID, { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()), - new PendingWait('question', RpcId('r2'), SID, + new PendingWait('question', 'r2' as SessionInteractionId, SID, { questions: [{ id: 'q1', question: '选择' }] }, vi.fn()), ], }) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index b37e65c9ea..7ca5fd4e6d 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -14,7 +14,7 @@ import { import type { ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition, - ConversationViewNode, ToolResultNode, TurnLocation, + ConversationViewNode, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -123,10 +123,12 @@ function matched(input: ConversationEventInput, role: ConversationMatch['role']) return { ...input, role, location: { kind: 'unresolved' } } } +type WireCallView = Extract, { for: 'call' }>['view'] + function call( seq: number, callId: string, - view: ToolResultNode['callView'], + view: WireCallView | null, turn = 1, ): ConversationEventInput { return at( @@ -148,7 +150,7 @@ function result(seq: number, callId: string, isError = false, turn = 1): Convers }) } -function diff(...paths: string[]): ToolResultNode['callView'] { +function diff(...paths: string[]): WireCallView { return { card: 'diff', title: `Write ${paths[0] ?? ''}`, diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), @@ -156,7 +158,7 @@ function diff(...paths: string[]): ToolResultNode['callView'] { } } -function edit(path: string): ToolResultNode['callView'] { +function edit(path: string): WireCallView { return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] } } diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index 19ae476a37..f1570eec9c 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -39,9 +39,7 @@ async function bench() { const locale = new LocaleRuntime(ctx) locale.setLocale('en') ctx.provide('locale', locale) - // The plugin injects `remote`; forwarded events reach it through the same - // `$dispatch` handoff the connection sink makes. - new TestRemote(ctx) + const remote = new TestRemote(ctx) ctx.slots.register({ name: 'root', children: { @@ -90,7 +88,7 @@ async function bench() { const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { - ctx, fiber, values, commands, + ctx, fiber, values, commands, remote, setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r }, decoration: () => decoration, permissionRow: () => ctx.slots.entries('settings.general.item') @@ -164,8 +162,8 @@ describe('ui-permission browser plugin', () => { it('disposal removes the decoration (HMR safety)', async () => { const b = await bench() expect(b.decoration()).toBeDefined() - b.ctx.remote.$dispatch('settings/document-updated', ['another', 1]) - b.ctx.remote.$dispatch('settings/document-updated', ['permission', 1]) + b.remote.emit('settings/document-updated', ['another', 1]) + b.remote.emit('settings/document-updated', ['permission', 1]) b.ctx.emit('connection/reset') await b.fiber.dispose() expect(b.decoration()).toBeUndefined() diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 18182602d2..6a36ccbf20 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -24,9 +24,7 @@ async function bench(isLoopback = true, settings?: object, services: object = {} const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - // The plugins inject `remote`; forwarded events reach them through the - // same `$dispatch` handoff the connection sink makes. - new TestRemote(ctx) + const remote = new TestRemote(ctx) // Without a settings face the mirror's reads fail and stay contained; the // Models join itself never fetches until a section actually loads. The real // ui-settings apply also provides the settingsSchema service. @@ -35,7 +33,7 @@ async function bench(isLoopback = true, settings?: object, services: object = {} isLoopback, } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale } + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, remote } } function declare(slots: SlotRegistry): () => void { @@ -176,9 +174,9 @@ describe('pushed invalidations', () => { declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() // The fake wire face has no methods: a fetch attempt would throw. - b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1]) - b.ctx.remote.$dispatch('credentials/reference-updated', ['OPENAI_API_KEY']) - b.ctx.remote.$dispatch('llm/adapters-updated', []) + b.remote.emit('settings/document-updated', ['llm-pi-ai', 1]) + b.remote.emit('credentials/reference-updated', ['OPENAI_API_KEY']) + b.remote.emit('llm/adapters-updated', []) b.ctx.emit('connection/reset') }) @@ -210,7 +208,7 @@ describe('pushed invalidations', () => { )() injected.controller.store.update((state) => { state.status = 'ready' }) const load = vi.spyOn(injected.controller, 'load').mockResolvedValue() - b.ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY']) + b.remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY']) expect(load).toHaveBeenCalledTimes(1) }) @@ -252,7 +250,7 @@ describe('pushed invalidations', () => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false }) }) acknowledgement.current = WELCOME_NOTICE_VERSION - b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 1]) + b.remote.emit('settings/document-updated', ['ui-onboarding', 1]) await vi.waitFor(() => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) }) @@ -295,7 +293,7 @@ describe('pushed invalidations', () => { expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1) revision = 2 - b.ctx.remote.$dispatch('settings/document-updated', ['llm-test', revision]) + b.remote.emit('settings/document-updated', ['llm-test', revision]) await vi.waitFor(() => { expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2) diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 539096cfcc..06ec8f00b2 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -42,10 +42,7 @@ async function bench(served?: string[]) { }, }, })) - // The section binds its scopes through the Settings surface's service, and - // forwarded Host events reach it through the same `$dispatch` handoff the - // connection sink makes. - new TestRemote(ctx) + const remote = new TestRemote(ctx) ctx.provide('connection', { isLoopback: true, api: { @@ -54,7 +51,7 @@ async function bench(served?: string[]) { }, } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings } + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote } } function declareRoot(slots: SlotRegistry): () => void { @@ -148,13 +145,13 @@ describe('ui-settings-plugins apply', () => { // Which namespaces the Host serves is a registration fact the wire never // announces on its own, so the tab rides the invalidation that can // accompany a changed composition. - const { ctx, slots, describeSettings } = await bench(['bash']) + const { ctx, slots, describeSettings, remote } = await bench(['bash']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) describeSettings.mockClear() - ctx.remote.$dispatch('settings/document-updated', ['bash', 1]) + remote.emit('settings/document-updated', ['bash', 1]) await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) }) @@ -172,7 +169,7 @@ describe('ui-settings-plugins apply', () => { }) it('re-reads the credential when the Host reports the watched reference changed', async () => { - const { ctx, slots, describeCredentials } = await bench() + const { ctx, slots, describeCredentials, remote } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) @@ -180,19 +177,19 @@ describe('ui-settings-plugins apply', () => { // A key written on another surface changes no settings section, so this // event is the only thing that reaches the card. - ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY']) + remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY']) await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) }) it('ignores a credential change for a reference no card watches', async () => { - const { ctx, slots, describeCredentials } = await bench() + const { ctx, slots, describeCredentials, remote } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) describeCredentials.mockClear() - ctx.remote.$dispatch('credentials/reference-updated', ['SOME_OTHER_KEY']) + remote.emit('credentials/reference-updated', ['SOME_OTHER_KEY']) await Promise.resolve() expect(describeCredentials).not.toHaveBeenCalled() diff --git a/packages/client/ui-settings/tests/plugin.client.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts index 2dbd28630f..ebd385b78d 100644 --- a/packages/client/ui-settings/tests/plugin.client.spec.ts +++ b/packages/client/ui-settings/tests/plugin.client.spec.ts @@ -15,8 +15,8 @@ function bench() { api: { settings: { describe: describeCall } }, isLoopback: true, } as never) - new TestRemote(ctx) - return { ctx, describeCall, fiber: ctx.plugin({ inject: [...inject], apply }) } + const remote = new TestRemote(ctx) + return { ctx, describeCall, remote, fiber: ctx.plugin({ inject: [...inject], apply }) } } describe('settings domain base plugin', () => { @@ -29,23 +29,23 @@ describe('settings domain base plugin', () => { }) it('refreshes the mirror on document commits and connection resets, once each', async () => { - const { ctx, describeCall, fiber } = bench() + const { ctx, describeCall, remote, fiber } = bench() await fiber.await() await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) - ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) + remote.emit('settings/document-updated', ['ui-test', 0]) await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) }) ctx.emit('connection/reset') await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) }) it('fiber disposal retires the service and its invalidation subscriptions', async () => { - const { ctx, describeCall, fiber } = bench() + const { ctx, describeCall, remote, fiber } = bench() await fiber.await() await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) await fiber.dispose() expect(ctx.get('settingsScope')).toBeUndefined() expect(ctx.get('settingsSchema')).toBeUndefined() - ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) + remote.emit('settings/document-updated', ['ui-test', 0]) ctx.emit('connection/reset') await Promise.resolve() expect(describeCall).toHaveBeenCalledTimes(1) diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 2f8da20713..6557381aee 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -74,10 +74,10 @@ async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) { ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) - new TestRemote(ctx) + const remote = new TestRemote(ctx) providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() - return { ctx, source: captured! } + return { ctx, source: captured!, remote } } const CATALOG: SkillRow[] = [ @@ -269,13 +269,13 @@ describe('catalog cache', () => { it('agent-preset/selected clears only the recomposed session', async () => { const { list, payloads } = countingList() - const { ctx, source } = await bench(list) + const { source, remote } = await bench(list) await source.candidates(proj('s1'), req('')) await source.candidates(proj('s2'), req('')) expect(payloads).toHaveLength(2) // The catalog a preset supplies is the preset's; the other session's // composition did not change, so its cached catalog still holds. - ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal']) + remote.emit('agent-preset/selected', [sid('s1'), 'minimal']) await source.candidates(proj('s1'), req('')) await source.candidates(proj('s2'), req('')) expect(payloads).toHaveLength(3) diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index 3f6b1c73ae..89397e260c 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -55,11 +55,10 @@ async function bench(isLoopback = true) { }) }) ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never) - // The settings transport and the forwarded-event port the plugin injects. - new TestRemote(ctx) + const events = new TestRemote(ctx) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { - ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, + ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, events, setHostPreference: (next: string) => { preference = next }, } } @@ -131,18 +130,18 @@ describe('ui-theme apply', () => { // The shared mirror read once at bench time; a Host-side change reaches it // through the document invalidation, exactly as production announces one. b.setHostPreference('dark') - b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeRuntime await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) // The mirror refreshes on every document commit (ns-agnostic); the scope's // derived value only moves when its own namespace changed. - b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0]) + b.events.emit('settings/document-updated', ['unrelated', 0]) await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(3) }) expect(theme.getTheme().preference).toBe('dark') b.setHostPreference('light') - b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') }) b.setHostPreference('dark') b.ctx.emit('connection/reset') @@ -166,7 +165,7 @@ describe('ui-theme apply', () => { b.describe.mockImplementationOnce(() => pending.promise) // The refresh hangs on the wire; the mirror keeps serving the last good // answer, so activation never blocks on the settings transport. - b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() const theme = b.ctx.get('theme') as ThemeRuntime @@ -179,7 +178,7 @@ describe('ui-theme apply', () => { it('ignores an invalid preference crossing the settings wire', async () => { const b = await bench() b.setHostPreference('sepia') - b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeRuntime await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(2) }) diff --git a/packages/client/ui-user-questions/src/client/contract/slots.ts b/packages/client/ui-user-questions/src/client/contract/slots.ts index c37351578e..04c5833646 100644 --- a/packages/client/ui-user-questions/src/client/contract/slots.ts +++ b/packages/client/ui-user-questions/src/client/contract/slots.ts @@ -11,13 +11,13 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots // entry) into every program that sees this contract, so PropsRuntime resolves. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { QuestionResponsePayload } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionQuestionResponse } from '@deepseek-ai/dsh-api-remotes/client' /** The pending question carrier the owner dispatches into the composer slot. */ export type QuestionWait = PendingWait<'question'> /** One structured answer batch covering every question of the request. */ -export type QuestionAnswer = QuestionResponsePayload['answer'] +export type QuestionAnswer = SessionQuestionResponse['answer'] /** One question of the request, as the carrier payload carries it. */ type QuestionItem = QuestionWait['payload']['questions'][number] diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 4479a50fed..29e2cc4ca8 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -5,8 +5,7 @@ import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionInteractionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts' import { QuestionComposer } from '../src/client/QuestionComposer.tsx' @@ -17,6 +16,8 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts afterEach(cleanup) const SID = 's1' as SessionId +const interactionId = (value: string): SessionInteractionId => value as SessionInteractionId +type QuestionRespond = ConstructorParameters>[4] const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] => (key => dict[key] ?? common[key] ?? key) @@ -52,15 +53,18 @@ const questions = (): QuestionWait['payload']['questions'] => [{ /** Carrier fixture over a scripted respond carrier. */ function wait( payload: QuestionWait['payload'] = { questions: questions() }, - respond = vi.fn(() => Promise.resolve({ accepted: true })), + respond: QuestionRespond = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { accepted: true as const }, + })), ) { - return { carrier: new PendingWait('question', RpcId('q-1'), SID, payload, respond), respond } + return { carrier: new PendingWait('question', interactionId('q-1'), SID, payload, respond), respond } } -/** The client-response envelope respond must have received for a decision. */ +/** The Session Controller response request emitted for a decision. */ function decidedEnvelope(label: string) { return { - type: 'client-response', rpcId: RpcId('q-1'), + interactionId: interactionId('q-1'), result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'plan-review', selected: [label] }] } } }, } } @@ -156,7 +160,7 @@ describe('PlanReviewPanel', () => { fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] })) expect(respond).toHaveBeenCalledWith({ - type: 'client-response', rpcId: RpcId('q-1'), + interactionId: interactionId('q-1'), result: { ok: false, error: { code: 'cancelled', message: 'the user closed this question request', details: {} }, @@ -188,7 +192,10 @@ describe('PlanReviewPanel', () => { it('re-arms the actions and says why when the decision does not land', async () => { const { carrier, respond } = wait( { questions: questions() }, - vi.fn(() => Promise.resolve({ accepted: false, reason: 'not-pending' })), + vi.fn(() => Promise.resolve({ + ok: true as const, + value: { accepted: false as const, reason: 'not-pending' as const }, + })), ) render() diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 6d999d9743..4263d97296 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -5,8 +5,7 @@ import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionInteractionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts' import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx' @@ -17,6 +16,8 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts afterEach(cleanup) const SID = 's1' as SessionId +const interactionId = (value: string): SessionInteractionId => value as SessionInteractionId +type QuestionRespond = ConstructorParameters>[4] const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] => (key => dict[key] ?? common[key] ?? key) @@ -56,16 +57,22 @@ const QUESTIONS = [ ] /** Carrier fixture: a real PendingWait over a scripted respond carrier. */ -function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve({ accepted: true }))) { +function wait( + id = 'question-1', + respond: QuestionRespond = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { accepted: true as const }, + })), +) { const carrier = new PendingWait( - 'question', RpcId(rpcId), SID, { questions: QUESTIONS }, respond) + 'question', interactionId(id), SID, { questions: QUESTIONS }, respond) return { carrier, respond } } -/** The client-response envelope respond must have received for an answer batch. */ -function answeredEnvelope(rpcId: string, answers: object[]) { +/** The Session Controller response request emitted for an answer batch. */ +function answeredEnvelope(id: string, answers: object[]) { return { - type: 'client-response', rpcId: RpcId(rpcId), + interactionId: interactionId(id), result: { ok: true, value: { sessionId: SID, answer: { answers } } }, } } @@ -123,7 +130,7 @@ describe('QuestionComposer', () => { it('renders plan detail through the shared assistant Markdown primitive', () => { const carrier = new PendingWait( 'question', - RpcId('markdown-plan'), + interactionId('markdown-plan'), SID, { questions: [{ @@ -242,7 +249,7 @@ describe('QuestionComposer', () => { it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => { const respond = vi.fn() - .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' }) + .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'bad-response' } }) .mockRejectedValueOnce(new Error('第二次取消失败')) const { carrier } = wait('question-1', respond) render() @@ -288,9 +295,9 @@ describe('QuestionComposer', () => { }) it('renders chrome copy through the English dictionary', () => { - const respond = vi.fn(() => Promise.resolve({ accepted: true })) + const respond = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const carrier = new PendingWait( - 'question', RpcId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond) + 'question', interactionId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond) render() expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy() expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy() @@ -312,8 +319,8 @@ describe('QuestionComposer', () => { describe('PendingQuestion domain face', () => { it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => { const respond = vi.fn() - .mockResolvedValueOnce({ accepted: true }) - .mockResolvedValueOnce({ accepted: false, reason: 'not-pending' }) + .mockResolvedValueOnce({ ok: true, value: { accepted: true } }) + .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'not-pending' } }) const question = new PendingQuestion(wait('rq', respond).carrier) const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] } await expect(question.answer(batch)).resolves.toBeUndefined() @@ -323,12 +330,12 @@ describe('PendingQuestion domain face', () => { it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => { const respond = vi.fn() - .mockResolvedValueOnce({ accepted: true }) - .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' }) + .mockResolvedValueOnce({ ok: true, value: { accepted: true } }) + .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'bad-response' } }) const question = new PendingQuestion(wait('rc', respond).carrier) await expect(question.cancel()).resolves.toBeUndefined() expect(respond).toHaveBeenCalledWith({ - type: 'client-response', rpcId: RpcId('rc'), + interactionId: interactionId('rc'), result: { ok: false, error: { code: 'cancelled', message: 'the user closed this question request', details: {} }, diff --git a/packages/client/web/src/boot.ts b/packages/client/web/src/boot.ts index c0aa6ad90f..71ab65c140 100644 --- a/packages/client/web/src/boot.ts +++ b/packages/client/web/src/boot.ts @@ -100,15 +100,8 @@ export class AppWebEntry { await mounted } - /** Prefetch stage-one bundles; their import path owns any eventual failure. */ + /** Prefetch stage-one bundles and their dynamic requests before concurrent plugin imports. */ private async prefetchImmediateTier(): Promise { - // A transport carrying loadBundle owns the bundle bytes; HTTP prefetch - // against its static deployment answers nothing. A transport without - // loadBundle leaves bundles on HTTP, prefetch included. - const transport = (globalThis as { - __DSH_TRANSPORT__?: { loadBundle?: unknown } - }).__DSH_TRANSPORT__ - if (transport?.loadBundle !== undefined) return await Promise.all(this.manifest.plugins .filter(row => row.immediately) .map(row => this.modules.prefetch(row.id).catch((_prefetchError: unknown) => { diff --git a/packages/client/web/tests/boot.client.spec.ts b/packages/client/web/tests/boot.client.spec.ts index 006ad0096c..def708d2c5 100644 --- a/packages/client/web/tests/boot.client.spec.ts +++ b/packages/client/web/tests/boot.client.spec.ts @@ -9,13 +9,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AppWebEntry } from '../src/boot.ts' const MODULES_ID = '@deepseek-ai/dsh-client-modules' +const PROVIDER_CLIENT_ID = 'provider/client' +const RUNTIME_CLIENT_ID = 'runtime/client' const win = globalThis as DshWindow +const transportGlobal = globalThis as { + __DSH_TRANSPORT__?: { loadBundle(url: string): Promise } +} const moduleFace = modulesClient as unknown as Record afterEach(() => { vi.restoreAllMocks() delete win.__DSH_BOOT__ delete win.__ModuleLoader__ + delete transportGlobal.__DSH_TRANSPORT__ document.body.innerHTML = '' }) @@ -80,6 +86,69 @@ describe('bootstrap failure rendering', () => { }) describe('plugin activation', () => { + it('prefetches a parser-loaded immediate row through the injected bundle transport', async () => { + const container = document.createElement('div') + document.body.append(container) + const target = installFacade() + const entries: WebBootEntry[] = [ + { id: 'consumer', url: '/consumer.js', rev: '1' }, + { + id: 'runtime', + url: '/runtime.js', + rev: '1', + external: [PROVIDER_CLIENT_ID], + immediately: true, + }, + { id: 'provider', url: '/provider.js', rev: '1' }, + { id: 'renderer', url: '/renderer.js', rev: '1' }, + ] + win.__DSH_BOOT__ = { rev: 'graph', entries } + target.load({ + id: 'runtime', + factory: require => ({ + apply: () => {}, + marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker, + }), + }) + const loaded: string[] = [] + const registrations = new Map([ + ['/consumer.js', { + id: 'consumer', + factory: require => ({ + apply: () => { + expect((require(RUNTIME_CLIENT_ID) as { marker: string }).marker).toBe('provider') + }, + }), + }], + ['/provider.js', { + id: 'provider', + factory: () => ({ apply: () => {}, marker: 'provider' }), + }], + ['/renderer.js', { + id: 'renderer', + factory: () => ({ + apply: (ctx: Context) => { + ctx.reflect.provide('uiRenderer', { mount: () => () => {} }) + }, + }), + }], + ]) + transportGlobal.__DSH_TRANSPORT__ = { + loadBundle: async (url) => { + loaded.push(url) + const registration = registrations.get(url) + if (registration === undefined) throw new Error(`missing fixture registration ${url}`) + target.load(registration) + }, + } + + const entry = new AppWebEntry(container) + await entry.run() + + expect(loaded).toEqual(['/provider.js', '/consumer.js', '/renderer.js']) + await entry.dispose() + }) + it('allows a modules-dependent row to be created before the modules row', async () => { const events: string[] = [] const container = document.createElement('div') diff --git a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts index 86ae5d9dad..6f5711e0d1 100644 --- a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts @@ -16,7 +16,7 @@ import type { } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { DynamicCordisInvokeResult } from '@deepseek-ai/dsh-api-remotes/client' -// Type-only: resolves `ctx.remote` and with it the `$on`/`$dispatch` surface. +// Type-only: resolves the `ctx.remote.$on` surface. import type {} from '@deepseek-ai/dsh-api-gateway/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import * as NodeHalf from '../src/index.ts' @@ -31,15 +31,6 @@ const USER_RUN = { agentId: AGENT, pluginId: PLUGIN, packageId: PACKAGE, mode: 'run' as const, hasClientHalf: true, } -/** - * Deliver one forwarded Host event the way the runtime's frame bridge does: the - * bridge hands `host/remote-event` to the Remote service, which fans it out to - * `$on` subscribers with the Host's own argument list. - */ -function forward(ctx: Context, event: string, payload: object): void { - ctx.remote.$dispatch(event, [payload]) -} - interface Bench { ctx: Context /** Source the host hands over for the next run. */ @@ -67,6 +58,8 @@ interface Bench { }[] /** Whether the namespace refuses the next render-failure report. */ reportRefused: { current: boolean } + /** Drive one forwarded Host event through the test-owned subscription table. */ + forward: (event: string, payload: object) => void /** * Report one entry crash the way the renderer's boundary does. Production calls * this from ui-renderer's boundary through the render host; a test has no React @@ -161,6 +154,11 @@ async function boot(): Promise { // this plugin's subscriptions, so registration order and delivery are all the // stub owes (api-gateway covers isolation and disposal on the real one). const listeners = new Map void)[]>() + const forward = (event: string, payload: object): void => { + for (const listener of [...listeners.get(event) ?? []]) { + (listener as (...args: readonly unknown[]) => void)(payload) + } + } const remote = { dynamicCordisRunner: namespace, $on: (event: string, listener: (...args: never[]) => void) => { @@ -172,11 +170,6 @@ async function boot(): Promise { if (at >= 0) bucket.splice(at, 1) } }, - $dispatch: (event: string, args: readonly unknown[]) => { - for (const listener of [...listeners.get(event) ?? []]) { - (listener as (...a: readonly unknown[]) => void)(...args) - } - }, } ctx.reflect.provide('remote', remote) ctx.reflect.provide('remote.dynamicCordisRunner', namespace) @@ -191,6 +184,7 @@ async function boot(): Promise { invokeThrow, renderFailures, reportRefused, + forward, crash: (slot, entry, abdicate, error) => { const core = (ctx.slots as unknown as { _core: { reportEntryError(key: string, entry: unknown, error: unknown, info: { abdicate: boolean }): void } @@ -213,7 +207,7 @@ describe('browser half', () => { const bench = await boot() await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN) expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(true) - forward(bench.ctx, 'cordis/dynamic-retract', { + bench.forward('cordis/dynamic-retract', { pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, }) await bench.settle() @@ -350,7 +344,7 @@ describe('browser half', () => { it('answers a run request after the surface approves it', async () => { const bench = await boot() const request = 'rr-1' as ApprovalRequestId - forward(bench.ctx, 'cordis/request-run', { + bench.forward('cordis/request-run', { requestId: request, agentId: AGENT, pluginId: PLUGIN, @@ -383,7 +377,7 @@ describe('browser half', () => { it('drops the affordance when another page answers the request', async () => { const bench = await boot() const request = 'rr-2' as ApprovalRequestId - forward(bench.ctx, 'cordis/request-run', { + bench.forward('cordis/request-run', { requestId: request, agentId: AGENT, pluginId: PLUGIN, @@ -394,7 +388,7 @@ describe('browser half', () => { requiresApproval: true, }) await bench.settle() - forward(bench.ctx, 'cordis/request-run-resolved', { + bench.forward('cordis/request-run-resolved', { requestId: request, outcome: 'approved', }) await bench.settle() @@ -407,7 +401,7 @@ describe('browser half', () => { it('exposes the refusal and the load observer on the face', async () => { const bench = await boot() const request = 'rr-3' as ApprovalRequestId - forward(bench.ctx, 'cordis/request-run', { + bench.forward('cordis/request-run', { requestId: request, agentId: AGENT, pluginId: PLUGIN, diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e21c58eb42..b0fd7abafe 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -3,46 +3,26 @@ * narrow RpcRequest

and echoes request.rpcId on the RpcResponse. */ -import { randomUUID } from 'node:crypto' -import { mkdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname } from 'node:path' -import { z as zod } from 'zod' import type { Context } from '@deepseek-ai/cordis' -import { installModelSelection } from '@deepseek-ai/dsh-agent' -import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' -import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session' -import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' import { isUserInvocable } from '@deepseek-ai/dsh-skill' -import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' -import { - workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, -} from '@deepseek-ai/dsh-workspace' -// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import { InvalidPresetIdError, PresetExistsError, PresetMountError, PresetNotWritableError, resolveSessionPreset, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets' -import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, - ModelCatalogFailure, ModelProviderGroup, - ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, - QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, JobView, ToolEventView, - WorkspaceId, WorkspaceView, + ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, + SettingsNamespaceView, SubagentAddress, } from './api/index.ts' +import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types' +import { ApiSessionNotFound, buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, @@ -53,28 +33,11 @@ import { type SessionLogCompressionLevel, } from './session-export.ts' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' -import { - SESSION_SEARCH_RESULT_LIMIT, - SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, - truncateUnicodeCodePoints, -} from './api/session-search.ts' -// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. -import type {} from '@deepseek-ai/dsh-session-projection' -// Type-only: resolves `ctx.get('tasks')` to the background job registry. -import type {} from '@deepseek-ai/dsh-jobs' -import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' -// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column). -import type {} from '@deepseek-ai/dsh-session-projection-cache' // GoalError narrows domain rejections to their stable codes at the wire boundary. import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' // Type-only edges: resolve the command-change stream and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' -// Type-only: the dynamic-package runner's forwarded-event declarations. Its -// client-safe `./types` subpath deliberately, not the package root — the root -// merges `ctx.dynamicCordisRunner`, and a dependency on that package would -// rebuild the api-remotes cycle this direction exists to avoid. -import type {} from '@deepseek-ai/dsh-cordis-host-runner/types' import type {} from '@deepseek-ai/dsh-skill' // The settings/credentials seams: brand guards run at this wire boundary; the // service reads stay optional (`ctx.get`) so a composition without either @@ -82,115 +45,11 @@ import type {} from '@deepseek-ai/dsh-skill' import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings' import { credentialRef } from '@deepseek-ai/dsh-credentials' -// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`. -import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' -import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { ScopeKey } from '@deepseek-ai/dsh-scope' -import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' -// Side-effect type import: resolves the `approval/request` waterfall and -// `ctx.get('approval')` without a value dependency on the seam (optional composition). -import type {} from '@deepseek-ai/dsh-user-approval' -import { approvalResponsePayloadSchema } from './api/approvals.schema.ts' -import { imageLimitsProjectionSchema, sessionListMetadataProjectionSchema } from './api/sessions.schema.ts' -import { questionResponsePayloadSchema } from './api/questions.schema.ts' -import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts' -import { RpcId } from './api/rpc.ts' -import type { - AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest, -} from '@deepseek-ai/dsh-user-questions' -import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' +import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' -import { - ApiRemoteSessionNotFound as SessionNotFound, - ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership, - API_REMOTE_FORWARDED_EVENTS, - apiRemoteSubagentOwnershipError, - createApiRemoteAgentResolver, - hasApiRemoteSubagentOwner, - inspectApiRemoteSession, -} from '@deepseek-ai/dsh-api-remotes' import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' -/** Page size when history is called without maxMessages. */ -const DEFAULT_MAX_MESSAGES = 50 - -/** Provider work budget: at most 100 calls and 2,000 inspected hits. */ -const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 - -/** Bound cold-log stat fan-out and settle each started batch before cancellation returns. */ -const COLD_SUMMARY_BATCH_SIZE = 16 -/** Default maximum artifact size eligible for one cold blankness read. */ -export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024 - -/** Conversation message event types (the pagination counting unit). */ -const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) - -/** Validate one prompt as a batch before publishing any durable image object. */ -async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise { - if (content.every(part => part.type === 'text')) { - return content.map(part => ({ type: 'text', text: part.text })) - } - const refs = await admitEncodedImages(ctx.attachments, content.filter(part => part.type === 'image')) - let next = 0 - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - // admitEncodedImages returns one reference per image part in order. - : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) -} - -/** Search durable content for an image reference, including nested tool results. */ -function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined { - if (!Array.isArray(content)) return undefined - for (const value of content) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { type?: unknown; attachment?: unknown; content?: unknown } - if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { - const ref = block.attachment as ImageAttachmentRef - if (match(ref)) return ref - } - if (block.type === 'tool-result') { - const nested = imageBlockIn(block.content, match) - if (nested !== undefined) return nested - } - } - return undefined -} - -/** Search every durable event carrier that can own model-visible content. */ -function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined { - const data = event.data as { - content?: unknown - message?: { content?: unknown } - inserted?: Array<{ content?: unknown }> - chunk?: { type?: unknown; block?: unknown } - } - const direct = imageBlockIn(data.content, match) - if (direct !== undefined) return direct - if (data.message !== undefined) { - const wrapped = imageBlockIn(data.message.content, match) - if (wrapped !== undefined) return wrapped - } - if (data.inserted !== undefined) { - for (const message of data.inserted) { - const inserted = imageBlockIn(message.content, match) - if (inserted !== undefined) return inserted - } - } - if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') { - return imageBlockIn([data.chunk.block], match) - } - return undefined -} - -/** Resolve the first reference matching one opaque id. */ -function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined { - for (const event of events) { - const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId) - if (found !== undefined) return found - } - return undefined -} - /** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ @@ -215,120 +74,19 @@ function isAborted(signal: AbortSignal): boolean { return signal.aborted } -/** - * Message-boundary pagination: count maxMessages append-origin messages - * backwards from the window tail. Replacement copies never entered the - * conversation a reader sees — they restate a shadowed range for the model - * alone — so they consume no quota; the page stays one contiguous raw range, - * which keeps a compaction's log-only `compaction/summary` record on the same page as its - * replacement. The cut is the starting seq of the oldest message group (chunks - * group via sourceEventSeqs — never cut mid-message). The tail page naturally - * includes the in-progress partial. - */ -function paginate( - events: readonly SessionEvent[], - beforeSeq: number | undefined, - maxMessages: number, -): { events: SessionEvent[]; hasMore: boolean } { - const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq) - let count = 0 - let cut = 0 - for (let i = window.length - 1; i >= 0; i--) { - const event = window[i] as SessionEvent - if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue - count++ - const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs - let groupStart = event.seq - if (sources !== undefined) { - for (const source of sources) { - if (source < groupStart) groupStart = source - } - } - if (count >= maxMessages) { - cut = groupStart - break - } - } - const page = window.filter(event => event.seq >= cut) - return { events: page, hasMore: cut > 0 } -} - /** Wrap an ok result echoing the request's rpcId. */ function ok(request: RpcRequest, value: T): RpcResponse { return { rpcId: request.rpcId, result: { ok: true, value } } } -/** - * Build the provider/model catalog over every registered route. Shared by the - * session-scoped `session.models` and host-scoped `llm.models`. Catalog - * membership stays advisory: an unlisted session selection remains valid for - * provider dispatch, but is not injected back into the selector after its - * owning catalog stops advertising it. Per-provider failures ride `failures` - * without failing the sound groups; groups that advertise nothing are dropped. - */ -async function buildModelCatalog(ctx: Context): Promise<{ - groups: ModelProviderGroup[] - failures: ModelCatalogFailure[] -}> { - const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => { - try { - const models = await ctx.llm.listModels(provider.id) - const entries = await Promise.all(models.map(async (model) => { - const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id) - const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined - ? undefined - : { - efforts: resolved.reasoning.efforts.map(effort => ({ - id: effort.id, - name: effort.name, - ...effort.description === undefined - ? {} - : { description: effort.description }, - })), - ...resolved.reasoning.defaultEffort === undefined - ? {} - : { defaultEffort: resolved.reasoning.defaultEffort }, - } - return { - id: model.id, - name: model.name, - ...model.description === undefined ? {} : { description: model.description }, - ...reasoning === undefined ? {} : { reasoning }, - } - })) - const group: ModelProviderGroup = { - id: provider.id, - name: provider.name, - models: entries, - } - return { kind: 'group' as const, group } - } catch (error: unknown) { - const failure: ModelCatalogFailure = { - id: provider.id, - name: provider.name, - message: error instanceof Error ? error.message : String(error), - } - return { kind: 'failure' as const, failure } - } - })) - return { - groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []).filter(group => group.models.length > 0), - failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), - } -} - /** Wrap an error result echoing the request's rpcId. */ function err(request: RpcRequest, error: RpcError): RpcResponse { return { rpcId: request.rpcId, result: { ok: false, error } } } /** - * The RPC refusal a preset failure becomes, or undefined when the failure is - * about something else. - * - * Both the session-create path and the switch path can be handed the same two - * failures, and a client that has to branch on the code needs them worded the - * same from either. + * Map an agent-preset selection failure onto its stable RPC refusal, or leave + * unrelated failures to the caller. * @param request - the request being answered. * @param error - the thrown value. * @returns the refusal, or undefined when the caller should keep handling. @@ -351,93 +109,6 @@ function presetFailure(request: RpcRequest, error: unknown): RpcRespons return undefined } -/** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */ -class FrameQueue { - private buffer: F[] = [] - private waiter: (() => void) | undefined - private done = false - - push(item: F): void { - if (this.done) return - this.buffer.push(item) - this.waiter?.() - } - - end(): void { - this.done = true - this.waiter?.() - } - - async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator { - const onAbort = (): void => { this.end() } - signal.addEventListener('abort', onAbort, { once: true }) - try { - while (true) { - while (this.buffer.length > 0) yield this.buffer.shift() as F - if (this.done || signal.aborted) return - await new Promise((resolve) => { this.waiter = resolve }) - this.waiter = undefined - } - } finally { - signal.removeEventListener('abort', onAbort) - cleanup() - } - } -} - -/** - * Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable - * frames — approval/question requested — mint their stable id in their - * pending registries instead). - */ -function frame(payload: F): RpcRequest { - return { rpcId: RpcId(randomUUID()), payload } -} - -/** - * Narrow one allowlisted host event's argument list to the JSON values the - * wrapper frame carries. A rejected argument is an allowlist mistake (the - * forwarded path applies no projection), not hostile input, so it throws rather - * than degrading to a lossy frame. The throw surfaces where the forwarding - * listener runs, so the emitter's own listener containment logs it and drops - * that frame — loud in the Host log, not at load or at the emit. Exported for - * the test that owns this decision: every currently allowlisted event has a - * statically JSON-safe payload, so a type-legal `ctx.emit` cannot reach the - * rejection branch. - * @param event - forwarded host event name, named in the failure. - * @param args - the emitter's argument list. - * @returns the same arguments typed as JSON values. - */ -export function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] { - for (const [index, arg] of args.entries()) { - if (!isJsonValue(arg)) { - throw new Error(`forwarded host event "${event}" argument ${index} is not lossless JSON data`) - } - } - return args as JsonValue[] -} - -/** Queue the subscription baseline frame. */ -function subscribeSession(queue: FrameQueue>, session: Session): void { - queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) -} - -/** - * Project registry snapshots onto the wire view, dropping the three internal - * fields {@link JobView} documents as absent. - */ -function jobViews(snapshots: readonly JobSnapshot[]): JobView[] { - return snapshots.map(job => ({ - id: job.id, - kind: job.kind, - label: job.label, - status: job.status, - ...job.detail === undefined ? {} : { detail: job.detail }, - startedAt: job.startedAt, - ...job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }, - })) -} - /** * Whether the session's conversation has started: no turn has run yet (a * turn is one model-loop execution). Standalone plugin events — command @@ -449,122 +120,6 @@ function sessionBlank(session: Session): boolean { return !session.events.some(event => event.type === 'turn/start') } -/** Advance the Session-list hint projection by one committed event. */ -function applySessionListMetadata(state: SessionListMetadata, event: SessionEvent): SessionListMetadata { - const blank = state.blank && event.type !== 'turn/start' - const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user' - ? event.time - : state.lastPromptAt - return blank === state.blank && lastPromptAt === state.lastPromptAt - ? state - : { blank, lastPromptAt } -} - -/** Fold exact list metadata for an attached Session. */ -function sessionListMetadata(events: readonly SessionEvent[]): SessionListMetadata { - let state: SessionListMetadata = { blank: true, lastPromptAt: null } - for (const event of events) state = applySessionListMetadata(state, event) - return state -} - -/** Sort by creation or latest human prompt, whichever is newer. */ -function sessionListUpdatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number { - return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0) -} - -/** Shared Session-header projection for list baselines and creation frames. */ -function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): { - parentSessionId?: SessionId - origin?: 'subagent' - cwd?: string - agentPreset?: string -} { - // The preset comes from the log, not the header: a session that switched - // while blank ran its turns under the newer composition, and a picker - // showing the creation-time value would contradict what the model saw. - const agentPreset = resolveSessionPreset({ header, events }) - return { - ...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }, - ...header.origin === undefined ? {} : { origin: header.origin }, - ...header.cwd === undefined ? {} : { cwd: header.cwd }, - ...agentPreset === undefined ? {} : { agentPreset }, - } -} - -/** SessionSummary projection for attached (in-memory) sessions. */ -function summarize(session: Session, running: boolean): SessionSummary { - const metadata = sessionListMetadata(session.events) - return { - sessionId: session.id, - updatedAt: sessionListUpdatedAt(session.header, metadata), - running, - blank: metadata.blank, - ...sessionListFields(session.header, session.events), - } -} - -/** - * Verify a possibly blank cold Session only when its physical artifact passes - * the configured per-Session size check. A stale `blank: true`, an - * absent cache row, a large or location-less artifact, and read failures all - * resolve to visible (`false`); listing must never hide a conversation on a - * cache hint or an unavailable optimization. - */ -async function probeColdSessionMetadata( - ctx: Context, - persistence: SessionPersistence, - meta: SessionHeader, - maxBytes: number, - signal?: AbortSignal, -): Promise { - if (maxBytes === 0) return undefined - signal?.throwIfAborted() - const location = persistence.locate(meta) - if (location === undefined) return undefined - signal?.throwIfAborted() - let size: number - try { - size = (await stat(location.path)).size - } catch { - signal?.throwIfAborted() - return undefined - } - if (size > maxBytes) return undefined - try { - const { events } = await persistence.readFrom(meta.id, 0, signal) - signal?.throwIfAborted() - return sessionListMetadata(events) - } catch (error) { - signal?.throwIfAborted() - ctx.logger.warn(`session.list: blank probe for "${meta.id}" failed (serving it as visible): ${String(error)}`) - return undefined - } -} - -/** SessionSummary projection for a cold persisted Session. */ -async function summarizeCold( - ctx: Context, - persistence: SessionPersistence, - meta: SessionHeader, - metadata: SessionListMetadata | undefined, - blankProbeMaxBytes: number, - signal?: AbortSignal, -): Promise { - const probed = metadata?.blank === false - ? undefined - : await probeColdSessionMetadata(ctx, persistence, meta, blankProbeMaxBytes, signal) - return { - sessionId: meta.id, - updatedAt: sessionListUpdatedAt(meta, probed ?? metadata), - running: false, - blank: metadata?.blank === false ? false : probed?.blank ?? false, - // Header-only: reading the log for a blank-window preset switch would - // defeat the same index read, and attaching the session replaces this row - // with `summarize()`, which resolves the switch from the events. - ...sessionListFields(meta), - } -} - /** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */ function directoryError(error: unknown): RpcError { if (error instanceof DirectoryPickerError) { @@ -573,24 +128,11 @@ function directoryError(error: unknown): RpcError { return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } } -/** Resolved Agent model and project-directory defaults consumed by the API implementation. */ +/** Deployment metadata and Host integrations consumed by the API implementation. */ export interface ApiProxyDefaults { - /** - * The model selection a session starts from when its own log names none. Read on - * every access rather than captured, so a default saved during this process - * reaches the sessions that have not run a turn yet. - */ + /** Current deployment model selection reported by `host.describe`. */ defaultModelSelection: () => ModelSelection - /** - * Record a selection as the new default. Either absent, or a closure that - * may itself decline — the gateway plugin always passes one, and it no-ops - * when the deployment mounts no settings provider or when the write races - * service teardown. A switch then stays process-local. A rejection is - * reported and swallowed: the switch already applies to its own session, - * and undoing it because storage failed would be the worse outcome. - */ - saveDefaultModelSelection?: (selection: ModelSelection) => Promise - /** Default project directory for new sessions whose create request carries no cwd. */ + /** Project hint reported by `host.describe`; must match Session Controller's default cwd. */ cwd: string /** Native open-with-default-application; injectable for carrier tests. */ openPath?: (path: string, signal: AbortSignal) => Promise @@ -598,8 +140,6 @@ export interface ApiProxyDefaults { openTextFile?: (path: string, signal: AbortSignal) => Promise /** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */ sessionExportCompressionLevel?: SessionLogCompressionLevel - /** Maximum artifact size eligible for one cold blankness read. */ - coldBlankProbeMaxBytes?: number /** * Whether handing a path to the native opener can work at all — the * `hasDocument` capability the preset roster reports, and the switch @@ -610,235 +150,6 @@ export interface ApiProxyDefaults { canOpenPath?: () => boolean } -/** The tool/call payload fields the presenter path reads. */ -interface ToolCallData { callId: string; name: string; arguments: string } -/** - * One outstanding approval question: the stable server-request id, the frame - * material replayed to late mux subscribers, and the resolver that settles the - * answerer's promise back into `ctx.approval`. - */ -interface PendingApproval { - rpcId: RpcId - sessionId: SessionId - approvalId: ApprovalRequestId - toolName: string - callId?: CallId - reason?: string - resolve(outcome: ApprovalOutcome): void -} - -/** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */ -function requestedFrame(pending: PendingApproval): RpcRequest { - return { - rpcId: pending.rpcId, - payload: { - type: 'approval/requested', - sessionId: pending.sessionId, - approvalId: pending.approvalId, - toolName: pending.toolName, - ...pending.callId === undefined ? {} : { callId: pending.callId }, - ...pending.reason === undefined ? {} : { reason: pending.reason }, - }, - } -} - -/** One host-owned question wait, addressed by the stable server-request id. */ -interface PendingQuestion { - rpcId: RpcId - sessionId: SessionId - questions: AskUserQuestionItem[] - resolve: (answer: AskUserQuestionAnswer) => void - reject: (error: UserQuestionError) => void - signal?: AbortSignal - onAbort?: () => void -} - -/** Validate one answer batch against the exact question request it resolves. */ -function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean { - if (payload.sessionId !== pending.sessionId) return false - const answers = payload.answer.answers - if (answers.length !== pending.questions.length) return false - return answers.every((answer, index) => { - const question = pending.questions[index] as AskUserQuestionItem - if (answer.id !== question.id) return false - if (new Set(answer.selected).size !== answer.selected.length) return false - const custom = answer.custom?.trim() - if (custom !== undefined && custom === '') return false - if (question.multiSelect !== true) { - if (custom !== undefined && answer.selected.length > 0) return false - if (answer.selected.length > 1) return false - } - const labels = new Set(question.options?.map(option => option.label) ?? []) - return answer.selected.every(label => labels.has(label)) - }) -} - -/** - * Compute the render intent for a tool/call or tool/result event through the - * presenters registered at this moment; every other event type gets none. A - * result's presenter needs its call's parsed args — `argsFor` supplies them - * (live: the per-session call table; history: an in-page backscan), returning - * undefined when the pairing is unavailable (e.g. the call fell off the page), - * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall: - * the client's documented default (generic JSON card) covers every miss. - */ -function viewFor( - ctx: Context, - event: SessionEvent, - argsFor: (callId: string) => unknown, - // Presenters live with the definitions, and definitions live in the scope - // chain: a preset registers its tools into its standing layer. A live agent - // is a scope whose chain passes through its preset; a cold read passes the - // preset's standing key directly — no agent, no resume. An undefined scope - // sees only the global layer, which is the pre-preset deployment shape. - scope?: ScopeKey, -): ToolEventView | undefined { - try { - if (event.type === 'tool/call') { - const { name, arguments: raw } = event.data as ToolCallData - const view = ctx.tools.get(name, scope)?.presentCall?.(JSON.parse(raw)) - return view === undefined ? undefined : { for: 'call', view } - } - if (event.type === 'tool/result') { - const { message, meta } = event.data - const [result] = message.content - const callId = message.source.callId - const call = argsFor(callId) as { name: string; args: unknown } | undefined - if (call === undefined) return undefined - const view = ctx.tools.get(call.name, scope)?.presentResult?.(call.args, { - content: result.content, - isError: result.isError === true, - ...meta === undefined ? {} : { meta }, - }) - return view === undefined ? undefined : { for: 'result', view } - } - } catch (error: unknown) { - // A throwing presenter (or unparseable arguments) must not break delivery; - // the event still ships, just without a view. - console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`) - } - return undefined -} - -/** - * Resolve a tool/result's call pairing by scanning a window of events backwards - * for the matching tool/call. Used by the history path (the page is the - * window — a cross-page pairing soft-falls to no view) and by live-path table - * misses after a reconnect-eviction. - */ -function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined { - for (let i = events.length - 1; i >= 0; i--) { - const event = events[i] as SessionEvent - if (event.type !== 'tool/call') continue - const data = event.data as ToolCallData - if (data.callId !== callId) continue - try { - return { name: data.name, args: JSON.parse(data.arguments) } - } catch { - // Unparseable stored arguments: same soft-fall as a live parse failure. - return undefined - } - } - return undefined -} - -/** Render one detached history page through the same presenter path as ordinary history. */ -function historyPage( - ctx: Context, - events: readonly SessionEvent[], - beforeSeq: number | undefined, - maxMessages: number | undefined, - scope?: ScopeKey, -): { events: HistoryEntry[]; hasMore: boolean } { - const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES) - return { - events: page.events.map((event) => { - const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), scope) - return { event, ...view === undefined ? {} : { view } } - }), - hasMore: page.hasMore, - } -} - -/** - * The projection baseline for one history tail page: the registry's - * watermark-cache snapshot — one fully synchronous read (no await between the - * page slice and this), so all values and `asOfSeq` form a single consistent - * cut and `asOfSeq` equals the window tail event seq. The carrier holds zero - * domain knowledge (each value passed its unit's own schema inside the - * registry). An absent registry means the deployment has no projection seam: - * the whole block is absent and clients treat every key as capability-absent. - */ -/** - * Which session a transcript read is served from. An attached session is the - * live object and keeps appending, so its events and projection baseline are - * read together in one synchronous step; a detached one is already a frozen - * inspection. - */ -type HistorySource = - | { readonly kind: 'attached'; readonly session: Session } - | { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: SessionEvent[] } - -function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined { - const registry = ctx.get('sessionProjections') - if (registry === undefined) return undefined - return registry.snapshot(session) -} - -/** - * The projection baseline of one session.list row, fail-soft: attached - * sessions cut the registry's live watermark cache; cold sessions view the - * persisted projection cache's identity-checked stored rows (zero log loads - * either way — the listing use case the cache exists for). The block shape - * (values + asOfSeq) matches the history tail's, so a client seeds its - * value store under the same higher-seq-wins rule. Any failure — and an - * empty value set — yields an absent block: a listing without projections - * is degraded, never broken. - */ -function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined { - try { - const block = session !== undefined - ? ctx.get('sessionProjections')?.snapshot(session) - : ctx.get('sessionProjectionCache')?.cachedSnapshot(meta) - return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined - } catch (error) { - ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`) - return undefined - } -} - -/** Projection baseline for a detached history tail without Agent activation. */ -function detachedProjectionsFor( - ctx: Context, - events: readonly SessionEvent[], -): SessionProjectionsBlock | undefined { - const registry = ctx.get('sessionProjections') - if (registry === undefined) return undefined - return registry.restore({}, events, 0).snapshot -} - -/** - * Best-effort projections for one subagent history page, fail-soft like - * {@link listProjectionsFor}: a registered unit throwing on a corrupt payload - * never blocks transcript reading — the page is served without the block. - * @param ctx - context carrying the logger for the degradation warning. - * @param childSessionId - the child whose page is being decorated. - * @param compute - the arm-specific fold (live watermark or detached restore). - * @returns the projections block, or undefined when the fold failed. - */ -function subagentHistoryProjections( - ctx: Context, - childSessionId: SessionId, - compute: () => SessionProjectionsBlock | undefined, -): SessionProjectionsBlock | undefined { - try { - return compute() - } catch (error) { - ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`) - return undefined - } -} - /** Map continuation admission failures without exposing provider details. */ function subagentPromptError( request: RpcRequest<{ childSessionId: SessionId }>, @@ -966,96 +277,15 @@ function presetError(agentPreset: string, error: unknown): RpcError { return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} } } -class AgentPresetConflict extends Error { - constructor( - readonly sessionId: SessionId, - readonly requestedPreset: string, - readonly existingPreset: string | undefined, - ) { - super( - existingPreset === undefined - ? `session "${sessionId}" records no agent preset, so it cannot be adopted under one; ` - + 'a deployment composing no roster records none on any session — ' - : `session "${sessionId}" already runs agent preset ${JSON.stringify(existingPreset)}; ` - + `requested ${JSON.stringify(requestedPreset)}. A session's preset is fixed at creation.`, - ) - } -} - -/** Requested identity already belongs to a session with another project cwd. */ -class SessionCwdConflict extends Error { - constructor( - readonly sessionId: SessionId, - readonly requestedCwd: string, - readonly existingCwd: string | undefined, - ) { - super( - `session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; ` - + `requested ${JSON.stringify(requestedCwd)}`, - ) - } -} - -/** An explicit Host naming operation would duplicate another Workspace title. */ -class WorkspaceNameConflictError extends Error { - constructor(readonly workspaceName: string) { - super(`workspace name '${workspaceName}' is already in use`) - this.name = 'WorkspaceNameConflictError' - } -} - -/** Shared workspace-not-found error response of the workspace.* mutation rows. */ -function workspaceNotFound(request: RpcRequest, workspaceId: string): RpcResponse { - return err(request, { - code: 'workspace-not-found', - message: `workspace "${workspaceId}" not found`, - details: { workspaceId }, - }) -} - -/** Wire projection of one workspace entity (the workspace.* value row). */ -function workspaceView(workspace: Workspace): WorkspaceView { - return { - workspaceId: workspace.id, - path: workspace.path, - title: workspace.title, - sessionIds: [...workspace.sessionIds], - createdAt: workspace.createdAt, - updatedAt: workspace.updatedAt, - } -} - -/** Wire projection of the durable record carried by `domain/changed`. */ -function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView { - const record: WorkspaceRecord = workspaceRecord.parse(value) - return { - workspaceId: workspaceId as WorkspaceId, - path: record.path, - title: record.title, - sessionIds: [...record.sessionIds], - createdAt: record.createdAt, - updatedAt: record.updatedAt, - } -} - /** * Implement ApiProxy over a composed host context. - * @param ctx - a context with the Host spine and Workspace registry mounted. + * @param ctx - a context with the Host spine mounted. * @param defaults - host routing and project-directory defaults. * @returns the ApiProxy implementation. */ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL - const coldBlankProbeMaxBytes = defaults.coldBlankProbeMaxBytes - ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES - /** The seed model each create/resume declares; re-read so it never goes stale. */ - const agentOptions = (): AgentOptions => { - const { provider, model } = defaults.defaultModelSelection() - return { provider, model } - } - type WebModelSelectionRef = ModelSelectionRef & { current: ModelSelection } - const selections = new WeakMap() /** * Serializes `agentPreset.select` per session. Two concurrent selects both * pass the blank check, and the second `unmountPresetFor` then finds nothing @@ -1064,477 +294,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * not enforcement: the wire is reachable directly. */ const presetSwitches = new Map>() - /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ - const sessionCreations = new Map>() - /** Serializes path ownership and explicit title checks with Workspace mutations. */ - let workspaceCreationChain = Promise.resolve() - const pendingQuestions = new Map() - const pendingApprovals = new Map() - const muxQueues = new Set>>() - const imageAdmissionChains = new WeakMap>() + const agentFor = (sessionId: SessionId) => + ctx.sessionController.resolveAgent(sessionId) - /** Serialize image admission with model selection for one agent. */ - function serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { - const result = (imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation) - imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined)) - return result - } - - /** - * Install or return the session-local model selection that prompt assembly snapshots. - * - * Precedence, resolved on EVERY read rather than seeded once: a selection - * made in this process, else the session's own latest logged request/header, - * else the live Agent default. Re-reading keeps the two tiers exact in both - * directions: a session with a recorded request derives its selection from - * its log, while a blank session (New Session reuses one rather than minting - * another) reads any default saved after it was created. There is no create-time - * per-session override tier on this wire — if one returns (a create-options - * contribution), it must fold in between the selection and the log. - */ - function selectionFor(agent: Agent): WebModelSelectionRef { - const installed = selections.get(agent) - if (installed !== undefined) return installed - let picked: ModelSelection | undefined - const selection: WebModelSelectionRef = { - get current(): ModelSelection { - if (picked !== undefined) return picked - // Incrementally folded by the session, so a per-step read costs - // O(new events) rather than a rescan. - const logged = agent.session.requestHeader()?.config - if (logged === undefined) return defaults.defaultModelSelection() - return { - provider: logged.provider, - model: logged.model, - ...logged.reasoningEffort === undefined - ? {} - : { reasoningEffort: logged.reasoningEffort }, - } - }, - set current(next: ModelSelection) { - picked = next - }, - assembled: undefined, - } - installModelSelection(agent.ctx, selection) - selections.set(agent, selection) - return selection - } - - /** Pre-publication setup used by both fresh and resumed Web agents. */ - function installSelection(agentCtx: Context): void { - const agent = agentCtx.agent - if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent') - selectionFor(agent) - } - - /** - * Reject an attempt to run an existing session under a different preset. - * - * A caller that names no preset always adopts the session as it is, so the - * common paths — reconnecting, resuming, retrying a create — are unaffected. - * @param sessionId - the identity being adopted. - * @param requested - the preset the request named, if any. - * @param existing - the preset the session RUNS, if any; both callers resolve - * it from the log, which differs from the creation header once a blank - * session has switched. - * @throws when both are present and differ. - */ - function assertPresetUnchanged( - sessionId: SessionId, - requested: string | undefined, - existing: string | undefined, - ): void { - if (requested === undefined || requested === existing) return - throw new AgentPresetConflict(sessionId, requested, existing) - } - - /** - * Resolve the preset an agent will be composed from, and the setup that - * installs it. - * - * The id is resolved BEFORE the session exists because the session boundary - * snapshots `meta` before asynchronous setup begins — a preset discovered - * during setup could never reach the header. Mounting still happens in - * setup, where a failure rolls the whole creation back rather than leaving a - * published session whose capabilities are half-installed. - * - * A deployment with no preset roster composes nothing and every session - * shares the host composition, which is the behavior before presets existed. - * @param presetId - the requested preset, or `undefined` for the default. - * @returns the id to record on the header (absent without a roster) and the setup callback. - * @throws when the roster supplies no such preset. - */ - async function composeAgent(presetId: string | undefined): Promise<{ - agentPreset?: string - setup: (agentCtx: Context) => Promise - }> { - const presets = ctx.get('agentPresets') - if (presets === undefined) { - return { - setup: (agentCtx: Context) => { - installSelection(agentCtx) - return Promise.resolve() - }, - } - } - const resolvedId = (await presets.resolve(presetId)).id - return { - agentPreset: resolvedId, - setup: async (agentCtx: Context) => { - installSelection(agentCtx) - await presets.mount(agentCtx, resolvedId) - }, - } - } - - const hasSubagentOwner = ( - session: Pick, - agent: Agent | undefined, - ): boolean => hasApiRemoteSubagentOwner(ctx, session, agent) - const subagentOwnershipError = (sessionId: SessionId): RpcError => - apiRemoteSubagentOwnershipError(sessionId) - const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => - inspectApiRemoteSession(ctx, sessionId) - // Cold resume composes the preset the session recorded, for the same reason - // `session.create` does: its history was produced under that composition. - // Every generic entry point — prompt, models, commands — arrives here, so - // leaving it out meant a session opened after a restart ran on host tools - // and the deployment persona. Resolved from the LOG, not the header: a - // session that switched while blank ran its turns under the newer - // composition, and the header is written once at creation. Reading the - // header here would silently undo the switch on the next restart and - // restore that history under the old tool set. - const agentFor = createApiRemoteAgentResolver(ctx, { - agentOptions, - setup: async ({ meta, events }) => - (await composeAgent(resolveSessionPreset({ header: meta, events }))).setup, - }) - - /** Send one transient frame to every connected mux consumer. */ - function broadcast(payload: MuxFrame): void { - const envelope = frame(payload) - for (const queue of muxQueues) queue.push(envelope) - } - - // Projection change feed → session/projection push frames. The carrier - // mints the wire frame (the Service Definition package holds no wire vocabulary); the - // child activates only when a projection registry is composed, and the - // subscription unwinds with this gateway's fiber. - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.onChanged((session, key, value, seq) => { - broadcast({ type: 'session/projection', sessionId: session.id, key, value, seq }) - }) - }) - - // The cache supplies recency and a monotonic non-blank hint. A cached - // `blank: true` remains only a prefix fact and is verified on the cold path. - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ - key: 'sessionListMetadata', - stateSchema: sessionListMetadataProjectionSchema, - init: () => ({ blank: true, lastPromptAt: null }), - apply: applySessionListMetadata, - wire: { viewSchema: sessionListMetadataProjectionSchema, view: state => state }, - stateVersion: 1, - }) - }) - - // The imageLimits projection unit: the attachments config this proxy - // enforces at prompt admission, constant per host boot. `apply` keeps the - // same state reference for every event, so no change frames are ever - // pushed — baselines alone carry the value — and clients pre-check intake - // and label upload affordances from it. Registered here, not in the - // attachment Service Definition: dsh-llm depends on dsh-attachment, so the - // seam package cannot reference the projection registry without a cycle, - // and the per-message rules the value describes are this proxy's own - // admission checks. The child activates only while both seams are composed. - // `view` reading the live service instead of the (null) state is sanctioned - // exactly for boot-constant units: the value cannot change within a process - // lifetime, so the fold stays observationally pure, and a stale persisted - // cache row re-viewing to the current config is the correct outcome. - ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'imageLimits', null>({ - key: 'imageLimits', - stateSchema: zod.null(), - init: () => null, - apply: state => state, - wire: { viewSchema: imageLimitsProjectionSchema, view: () => projectionCtx.attachments.imageLimits }, - stateVersion: 1, - }) - }) - - /** Project both durable inbox lists, optionally including the splice currently being emitted. */ - const queueItems = ( - agent: Agent, - splice?: SessionEventMap['agent/inbox/spliced'], - ): QueuedInboxItem[] => { - const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => { - const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep - return splice?.target === target - ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted) - : messages - } - return [ - ...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, message })), - ...project('next-step').map(message => ({ - id: message.id, - // Only user-origin messages are steering; injected context (approval - // notices, task completion, attached snapshots) is not a user action - // and must not render as a pending steering bubble. - placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, - message, - })), - ] - } - - ctx.on('session/event', (session, event) => { - if (event.type !== 'agent/inbox/spliced') return - const agent = ctx.agents.get(session.id) - if (agent?.session !== session) return - broadcast({ type: 'session/queue', sessionId: session.id, items: queueItems(agent, event.data) }) - }) - - /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */ - function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void { - pendingQuestions.delete(pending.rpcId) - if (pending.signal !== undefined && pending.onAbort !== undefined) { - pending.signal.removeEventListener('abort', pending.onAbort) - } - broadcast({ - type: 'question/resolved', sessionId: pending.sessionId, - questionRpcId: pending.rpcId, outcome, - }) - } - - const disposeProvider = ctx.userQuestions.registerProvider({ - ask(request: AskUserQuestionRequest): Promise { - const sessionId = request.agent?.id - if (sessionId === undefined) { - return Promise.reject(new UserQuestionError( - 'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT')) - } - return new Promise((resolve, reject) => { - const rpcId = RpcId(randomUUID()) - const pending: PendingQuestion = { - rpcId, sessionId, questions: request.questions, resolve, reject, - ...(request.signal === undefined ? {} : { signal: request.signal }), - } - const onAbort = (): void => { - claimQuestion(pending, 'cancelled') - reject(new UserQuestionError( - 'ask_user_question was aborted before the user answered', 'ASK_ABORTED')) - } - pending.onAbort = onAbort - pendingQuestions.set(rpcId, pending) - request.signal?.addEventListener('abort', onAbort, { once: true }) - const envelope: RpcRequest = { - rpcId, - payload: { type: 'question/requested', sessionId, questions: request.questions }, - } - for (const queue of muxQueues) queue.push(envelope) - }) - }, - }) - ctx.effect(() => () => { - disposeProvider() - for (const pending of [...pendingQuestions.values()]) { - claimQuestion(pending, 'cancelled') - pending.reject(new UserQuestionError( - 'web user-questions provider was disposed', 'ASK_ABORTED')) - } - }, 'api-proxy: user-questions provider') - - // --- Approval pending registry ------------------------------------------ - // The proxy is the approval channel for every agent this host owns: an ask - // through `ctx.approval` becomes an answerable server-request on the mux - // stream (stable rpcId), settled by POST /api/respond. The entry survives - // client disconnects — mux-open replays still-pending requested frames with - // the same rpcId (the refresh-recovery baseline) — and withdraws on the - // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers. - if (ctx.get('approval') !== undefined) { - // Teardown parity with the question provider above: a gateway disposed - // while approvals are pending settles every entry as 'cancelled' (the - // service's fail-closed vocabulary), so no ask promise dangles past the - // proxy's lifetime and subscribers see the withdrawal. - ctx.effect(() => () => { - for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled') - }, 'api-proxy: approval registry teardown') - ctx.on('approval/request', (req, next) => { - // Dispatch rides a microtask behind the service's own signal check: an - // abort landing in that window would register the abort listener AFTER - // the signal fired — never invoked, entry pending forever, zombie frame - // on every mux replay. Settle synchronously instead of publishing. - if (req.signal?.aborted === true) return Promise.resolve('cancelled') - // The audit pair `approval/asked` is already appended by the service - // before dispatch, but dispatch rides a microtask: parallel tool calls - // can append several asked events before any answerer runs. THIS - // request's event is therefore the newest asked event that is still - // undecided, unclaimed by another pending entry, and — when the ask - // names a call — carries the same callId. - const events = req.agent.session.events - const claimed = new Set() - for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId) - const decided = new Set() - let approvalId: ApprovalRequestId | undefined - for (let i = events.length - 1; i >= 0; i -= 1) { - const event = events[i] as SessionEvent - if (event.type === 'approval/decided') { - decided.add(event.data.id) - } else if (event.type === 'approval/asked') { - if (decided.has(event.data.id) || claimed.has(event.data.id)) continue - // Symmetric pairing: a callId-bearing ask only takes its own call's - // record, and a callId-less ask only takes a callId-less record — - // so neither shape can steal the other's audit id under parallel - // asks. (Every shipped producer — the tool executor — passes callId; - // the callId-less arm guards any future non-tool asker.) - if ((req.callId ?? null) !== (event.data.callId ?? null)) continue - approvalId = event.data.id - break - } - } - // No asked event means the request bypassed the service's audit path — - // not this channel's question; delegate to the fail-closed default. - if (approvalId === undefined) return next() - const id = approvalId - return new Promise((resolve) => { - const settle = (outcome: ApprovalOutcome): void => { - /* v8 ignore next 3 -- defensive double-settle guard: respond() routes - through the pending table (a settled id is not-pending before it can - re-settle) and the first settle removes the abort listener, so no - reachable path settles twice; kept against future settle callers. */ - if (!pendingApprovals.delete(pending.rpcId)) return - req.signal?.removeEventListener('abort', onAbort) - broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome }) - // A cancelled ask was already settled by the service's own signal - // race, which discards this late resolution; resolving is a no-op - // there and keeps this promise from dangling forever. - resolve(outcome) - } - const onAbort = (): void => { settle('cancelled') } - const pending: PendingApproval = { - rpcId: RpcId(randomUUID()), - sessionId: req.agent.session.id, - approvalId: id, - toolName: req.toolName, - ...req.callId === undefined ? {} : { callId: req.callId }, - ...req.reason === undefined ? {} : { reason: req.reason }, - resolve: settle, - } - pendingApprovals.set(pending.rpcId, pending) - req.signal?.addEventListener('abort', onAbort, { once: true }) - const envelope = requestedFrame(pending) - for (const queue of muxQueues) queue.push(envelope) - }) - }) - } - - type SessionReadState = { - id: SessionId - header: SessionHeader - events: SessionEvent[] - } - - /** Read one stable session prefix without acquiring an Agent owner. */ - async function readSessionState(sessionId: SessionId): Promise { - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined) { - return { - id: attached.id, - header: attached.header, - events: [...attached.events], - } - } - const inspected = await inspectServable(sessionId) - return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } - } - - /** Resolve the Workspace inherited by a fork without making ordinary loose lineage grouped. */ - async function forkWorkspace(source: Pick): Promise { - const workspaces = ctx.workspaceRegistry.list() - const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id)) - if (direct !== undefined || source.header.origin !== 'subagent') return direct - - const lineage = await ctx.sessionQuery.traceSession(source.id) - for (const ancestor of lineage.ancestors) { - const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id)) - if (workspace !== undefined) return workspace - } - return undefined - } - - /** - * Resolve which session one transcript read is served from, without - * acquiring an Agent owner. This is the read's only asynchronous step - * besides ensuring the composition; {@link historyCutOf} takes the cut. - * @param sessionId - the transcript being read. - * @returns the attached session, or the inspected detached header and events. - * @throws {@link ApiRemoteSessionNotFound} when no project-backed session has that identity. - */ - async function historySourceFor(sessionId: SessionId): Promise { - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined) return { kind: 'attached', session: attached } - const inspected = await inspectServable(sessionId) - return { kind: 'detached', header: inspected.meta, events: inspected.events } - } - - /** - * The header and events {@link presenterScopeFor} reads to decide which - * composition a transcript ran under. - * @param source - the live or detached session this read is served from. - * @returns that session's creation header and its events. - */ - function sourceSession(source: HistorySource): PresetBearingSession { - if (source.kind === 'detached') return { header: source.header, events: source.events } - return { header: source.session.header, events: source.session.events } - } - - /** - * One transcript cut: the events and the projection baseline that describe - * the SAME log position. - * - * Synchronous, and the two reads sit next to each other, because an attached - * session keeps appending: an `await` between them would serve events cut at - * N beside a baseline folded to N+1, which is one response describing two - * moments. The caller does its awaiting before this call. - * @param source - the live or detached session this read is served from. - * @param includeProjections - whether the caller asked for the baseline (a tail page does). - * @returns the events and, when asked, the baseline for that same position. - */ - function historyCutOf( - source: HistorySource, - includeProjections: boolean, - ): { events: SessionEvent[]; projections?: SessionProjectionsBlock } { - if (source.kind === 'detached') { - const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined - return { events: source.events, ...projections === undefined ? {} : { projections } } - } - const events = [...source.session.events] - const projections = includeProjections ? projectionsFor(ctx, source.session) : undefined - return { events, ...projections === undefined ? {} : { projections } } - } - - /** - * The registry view scope a transcript's presenters resolve in. - * - * A live agent is that scope itself (its chain passes through its preset's - * standing layer). A cold session resolves its preset from the LOG, and the - * preset's STANDING key serves without resuming anything — ensuring the - * mount composes plugins but starts no agent, session, or turn. No roster, - * no recorded preset, or a preset the roster no longer supplies all fall - * back to the global layer: the transcript still serves, with the generic - * cards a viewless entry renders. - * - * Reading the header alone would render a session that switched while blank - * through the composition it was CREATED with. Every tool only the newer - * preset registers resolves to no presenter there, and the transcript - * silently degrades to generic cards for exactly the calls its history is - * made of. - * @param sessionId - the transcript being read. - * @param session - that session's header and log (attached or inspected). - * @returns the scope to pass to presenter lookups, or undefined for global. - */ - async function presenterScopeFor( + /** Resolve a Session's live or standing preset scope without resuming it. */ + async function sessionScopeFor( sessionId: SessionId, session: PresetBearingSession, ): Promise { @@ -1543,188 +307,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const presets = ctx.get('agentPresets') if (presets === undefined) return undefined try { - // An unrecorded preset (a log from before the roster existed) renders - // through the DEFAULT preset's standing layer: that is the composition - // an unnamed session composes, and presenters are pure display, - // so the worst a mismatch produces is the generic card it had anyway. return await presets.standingKeyFor(resolveSessionPreset(session)) } catch { - // Swallows only the unknown/unusable-preset rejection from the roster: - // a deleted or broken preset must degrade this read, never fail it. + // An unknown or unusable recorded preset falls back to the global registry. return undefined } } - /** Resolve one requested identity to a live agent, creating or resuming it once. */ - async function ensureSession( - sessionId: SessionId, - cwd: string, - checkPersistedIdentity: boolean, - presetId?: string, - ): Promise { - let creation = sessionCreations.get(sessionId) - if (creation === undefined) { - creation = (async () => { - const attached = ctx.sessions.get(sessionId) - const live = ctx.agents.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, live)) { - throw new SubagentSessionOwnership(sessionId) - } - if (live !== undefined) return live - - const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined - const stored = persistence === undefined - ? undefined - : (await persistence.list()).find(header => header.id === sessionId) - if (persistence !== undefined && stored !== undefined) { - const inspected = await persistence.inspect(sessionId) - // Ownership first: explicit-id adoption of a session-backed - // subagent must answer `agent-busy` regardless of the requested - // cwd (the api/commands.ts contract), not a cwd conflict. - if (hasSubagentOwner({ header: inspected.meta }, undefined)) { - throw new SubagentSessionOwnership(sessionId) - } - if (inspected.meta.cwd !== cwd) { - throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd) - } - // Resolved from the log, not the header: a session that switched - // while blank ran every turn under the newer composition. - const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events }) - assertPresetUnchanged(sessionId, presetId, storedPreset) - // The stored preset wins over anything the request names: a resumed - // session's history was produced under that composition, and - // rebuilding it differently would replay tool calls the model can no - // longer make. - return (await ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: agentOptions(), - setup: (await composeAgent(storedPreset)).setup, - })).agent - } - - try { - await mkdir(cwd, { recursive: true }) - } catch (error: unknown) { - throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) - } - const composition = await composeAgent(presetId) - return (await ctx.agents.create({ - sessionId, - agentOptions: agentOptions(), - meta: { - cwd, - ...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }, - }, - setup: composition.setup, - })).agent - })().catch((error: unknown) => { - // Another Host entry path may have published the same identity while - // this operation crossed an asynchronous persistence/filesystem step. - const live = ctx.agents.get(sessionId) - if (live !== undefined) { - if (hasSubagentOwner(live.session, live)) throw new SubagentSessionOwnership(sessionId) - return live - } - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, undefined)) { - throw new SubagentSessionOwnership(sessionId) - } - throw error - }).finally(() => { - sessionCreations.delete(sessionId) - }) - sessionCreations.set(sessionId, creation) - } - const agent = await creation - if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId) - // Beside the cwd check for the same reason, and after the await so it - // covers every path that yields a live agent — freshly created, adopted - // live, resumed from disk, or recovered by the concurrent-creation catch. - assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session)) - if (agent.session.header.cwd !== cwd) { - throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd) - } - return agent - } - - /** Resolve or create one path while holding the Host's workspace-create chain. */ - function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> { - const operation = workspaceCreationChain.then(async () => { - const existing = await ctx.workspaceRegistry.resolveByPath(path) - if (existing !== undefined) return { workspace: existing, created: false } - return { workspace: await ctx.workspaceRegistry.create(path), created: true } - }) - workspaceCreationChain = operation.then(() => undefined, () => undefined) - return operation - } - - /** - * Build the session.list baseline shared by listing and search visibility. - * Attached sessions come from memory; servable cold sessions merge from - * persistence, and the final order is newest-first. - */ - async function listVisibleSessionSummaries(signal?: AbortSignal): Promise { - signal?.throwIfAborted() - const summarizeAttached = (session: Session): SessionSummary => { - const agent = ctx.agents.get(session.id) - const projections = listProjectionsFor(ctx, session.header, session) - return { - ...summarize(session, agent?.status === 'running'), - ...projections === undefined ? {} : { projections }, - } - } - const items = ctx.sessions.list().map(summarizeAttached) - signal?.throwIfAborted() - const attached = new Set(items.map(item => item.sessionId)) - const persistence = ctx.get('sessionPersistence') - if (persistence !== undefined) { - const cold = (await persistence.list(signal)) - .filter(meta => !attached.has(meta.id) && meta.cwd !== undefined) - signal?.throwIfAborted() - for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) { - signal?.throwIfAborted() - const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE) - const settled = await Promise.allSettled( - batch.map(async (meta) => { - // Projection hints remain optional. Blank verification may read - // this Session's artifact only when it passes the configured size check. - const projections = listProjectionsFor(ctx, meta, undefined) - const summary = await summarizeCold( - ctx, - persistence, - meta, - projections?.values.sessionListMetadata, - coldBlankProbeMaxBytes, - signal, - ) - const attachedSession = ctx.sessions.get(meta.id) - if (attachedSession !== undefined) return summarizeAttached(attachedSession) - return { - ...summary, - ...projections === undefined ? {} : { projections }, - } - }), - ) - const summaries: SessionSummary[] = [] - let rejected = false - let failure: unknown - for (const result of settled) { - if (result.status === 'fulfilled') { - summaries.push(result.value) - } else if (!rejected) { - rejected = true - failure = result.reason - } - } - if (rejected) throw failure - signal?.throwIfAborted() - items.push(...summaries) - } - } - items.sort((a, b) => b.updatedAt - a.updatedAt) - return items - } - /** * Resolve the goal service THIS agent runs. * @@ -1766,47 +355,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } - /** - * Whether an adapter currently serves this provider, and therefore whether - * a session selecting it can start a turn. Catalog membership cannot answer - * it: an adapter may serve a model its own catalog stopped advertising, so - * a provider missing from the groups is not the same as one nothing serves. - * A composition with no llm registry at all cannot judge and says yes — - * the dispatch it would have refused fails on its own terms. - */ - function routeServed(provider: string): boolean { - const llm = ctx.get('llm') - return llm === undefined || llm.listProviders().some(entry => entry.id === provider) - } - - /** - * Resolve the addressed agent for a turn-starting method and refuse when no - * adapter serves its current selection: a provider nothing serves cannot start a - * turn, and letting it try spends the whole pre-step path to fail inside - * the adapter with a message about registration. Refusing here names the - * model the session is pointed at while the draft is still in the composer. - * This is `session.prompt`'s enforcement boundary: a client that disables - * its input is an affordance, and the method stays callable regardless. - */ - async function turnAgentFor( - request: RpcRequest, sessionId: SessionId, - ): Promise<{ agent: Agent } | { refused: RpcResponse }> { - const found = await agentFor(sessionId) - if ('error' in found) return { refused: err(request, found.error) } - const agent = found.agent - const selection = selectionFor(agent).current - if (!routeServed(selection.provider)) { - return { - refused: err(request, { - code: 'model-unavailable', - message: `no adapter serves provider "${selection.provider}"; select a model for this session`, - details: { provider: selection.provider, model: selection.model }, - }), - } - } - return { agent } - } - /** Missing-service report shared by the settings domain (skills-domain stance). */ function settingsAbsent(): RpcError { return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', details: {} } @@ -1936,603 +484,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return { - sessions: { - // Attached sessions summarize from memory; persisted-but-unattached (cold) - // sessions merge in from the persistence store so history survives restarts. - // Logs without a cwd are not served; every session records its project - // at create time. - async list(request) { - return ok(request, { items: await listVisibleSessionSummaries() }) - }, - - async search(request, signal) { - const cancelled = () => err<{ items: SessionSearchItem[]; hasMore: boolean }>(request, { - code: 'cancelled', - message: 'session search was aborted', - details: {}, - }) - if (isAborted(signal)) return cancelled() - const sessionQuery = ctx.get('sessionQuery') - if (sessionQuery === undefined) { - return err(request, { - code: 'internal', - message: 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query', - details: {}, - }) - } - try { - const visible = await listVisibleSessionSummaries(signal) - if (isAborted(signal)) return cancelled() - if (visible.length === 0) return ok(request, { items: [], hasMore: false }) - const visibleIds = new Set(visible.map(item => item.sessionId)) - const authorized: SessionSearchItem[] = [] - const acceptedIds = new Set() - const seenCursors = new Set() - let cursor: SessionSearchCursor | undefined - let providerCallCount = 0 - let providerPageLimit = SESSION_SEARCH_RESULT_LIMIT - while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) { - if (isAborted(signal)) return cancelled() - if (providerCallCount >= SESSION_SEARCH_PROVIDER_CALL_LIMIT) { - throw new Error( - `session search provider exceeded the ${SESSION_SEARCH_PROVIDER_CALL_LIMIT}-call work budget`, - ) - } - providerCallCount++ - const requestedCursor = cursor - const requestedPageLimit = providerPageLimit - let page - try { - page = await sessionQuery.searchSessions({ - query: request.payload.query, - eventFilters: [ - { kind: 'type', values: ['user/message', 'assistant/message'] }, - { kind: 'surface', values: ['current'] }, - ], - limit: requestedPageLimit, - ...requestedCursor === undefined ? {} : { cursor: requestedCursor }, - }, { signal }) - } catch (error: unknown) { - if (isAborted(signal)) return cancelled() - if ( - requestedCursor === undefined - && error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_INVALID_LIMIT' - && requestedPageLimit > 1 - ) { - providerPageLimit = Math.max(1, Math.floor(requestedPageLimit / 2)) - continue - } - if ( - requestedCursor !== undefined - && error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_STALE_CURSOR' - ) { - authorized.length = 0 - acceptedIds.clear() - seenCursors.clear() - cursor = undefined - continue - } - throw error - } - if (isAborted(signal)) return cancelled() - const providerItemCount = page.items.length - if (providerItemCount > requestedPageLimit) { - throw new Error( - `session search provider returned ${providerItemCount} items; maximum is ${requestedPageLimit}`, - ) - } - // Host visibility is the authorization boundary. Consume the - // provider's globally ranked results rather than binding every - // visible id into one SQLite statement, then require each hit to - // name a visible session and a current message from that same - // session before emitting its snippet. - for (const hit of page.items) { - if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue - if ( - !visibleIds.has(hit.header.id) - || hit.bestMatch.sessionId !== hit.header.id - || hit.bestMatch.surface !== 'current' - || !MESSAGE_TYPES.has(hit.bestMatch.type) - || acceptedIds.has(hit.header.id) - ) continue - const snippet = truncateUnicodeCodePoints( - hit.bestMatch.snippet, - SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, - ) - acceptedIds.add(hit.header.id) - authorized.push({ - sessionId: hit.header.id, - snippet, - }) - } - const nextCursor = page.nextCursor - if (nextCursor !== undefined) { - if (seenCursors.has(nextCursor)) { - throw new Error('session search provider repeated a continuation cursor') - } - seenCursors.add(nextCursor) - } - if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || nextCursor === undefined) break - cursor = nextCursor - } - return ok(request, { - items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT), - hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT, - }) - } catch (error: unknown) { - if ( - isAborted(signal) - || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') - ) return cancelled() - // XXX: Redact provider details before exposing this gateway beyond - // its current single-user local deployment. - return err(request, { - code: 'internal', - message: `session search failed: ${String(error)}`, - details: {}, - }) - } - }, - - async create(request) { - const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId - let workspace: Workspace | undefined - if (request.payload.workspaceId !== undefined) { - workspace = ctx.workspaceRegistry.get(brandWorkspaceId(request.payload.workspaceId)) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `workspace "${request.payload.workspaceId}" not found`, - details: { workspaceId: request.payload.workspaceId }, - }) - } - } - const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd - const requestedPreset = request.payload.agentPreset - try { - await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset) - } catch (error: unknown) { - if (error instanceof AgentPresetConflict) { - return err(request, { - code: 'agent-preset-conflict', - message: error.message, - details: { - sessionId: error.sessionId, - requestedPreset: error.requestedPreset, - ...error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }, - }, - }) - } - const refused = presetFailure(request, error) - if (refused !== undefined) return refused - if (error instanceof SessionCwdConflict) { - return err(request, { - code: 'session-conflict', - message: error.message, - details: { - sessionId: error.sessionId, - requestedCwd: error.requestedCwd, - ...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }, - }, - }) - } - if (error instanceof SubagentSessionOwnership) { - return err(request, subagentOwnershipError(error.sessionId)) - } - return err(request, { - code: 'internal', - message: `failed to create session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - if (workspace !== undefined) { - try { - await workspace.attachSession(sessionId) - } catch (error: unknown) { - return err(request, { - code: 'workspace-attach-failed', - message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`, - details: { sessionId, workspaceId: workspace.id }, - }) - } - } - // Echo the composition the session RUNS so a client can label it - // without waiting for the next list refresh — the create is the commit - // point that knows it (a caller that named none gets the default). - // Resolved from the log for the same reason `sessionListFields()` is: - // this handler also adopts an already-live session, and one that - // switched while blank runs a preset its header no longer names, so - // echoing the header would contradict both the adoption this call just - // allowed and the row `session.list` serves for the same session. - const created = ctx.agents.get(sessionId) - const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session) - return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } }) - }, - - async history(request) { - const { sessionId, beforeSeq, maxMessages } = request.payload - try { - const source = await historySourceFor(sessionId) - // Both awaits happen BEFORE the cut. Ensuring the recorded - // composition's standing mount is what registers its projection - // units, so a first cold read would otherwise serve a baseline - // missing every preset-owned key; and an attached session keeps - // appending, so awaiting between the two reads would pair events cut - // at N with a baseline folded to N+1. - const scope = await presenterScopeFor(sessionId, sourceSession(source)) - const cut = historyCutOf(source, beforeSeq === undefined) - const page = historyPage(ctx, cut.events, beforeSeq, maxMessages, scope) - return ok(request, { - events: page.events, - hasMore: page.hasMore, - ...cut.projections === undefined ? {} : { projections: cut.projections }, - }) - } catch (error: unknown) { - if (error instanceof SessionNotFound) { - return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } }) - } - return err(request, { - code: 'internal', - message: `history unavailable for session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - }, - - async models(request) { - const { sessionId } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const current = selectionFor(found.agent).current - const { groups, failures } = await buildModelCatalog(ctx) - const routable = routeServed(current.provider) - return ok(request, { current: { ...current }, routable, groups, failures }) - }, - - async selectModel(request) { - const { sessionId, provider, model, reasoningEffort } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - return serializeImageAdmission(found.agent, async () => { - try { - const resolved = await ctx.llm.resolveCallConfig({ - provider, - model, - ...reasoningEffort === undefined - ? {} - : { reasoningEffort: ReasoningEffortId(reasoningEffort) }, - }) - const selected: ModelSelection = { - provider: resolved.provider, - model: resolved.model, - ...resolved.reasoningEffort === undefined - ? {} - : { reasoningEffort: resolved.reasoningEffort }, - } - selectionFor(found.agent).current = selected - try { - await defaults.saveDefaultModelSelection?.(selected) - } catch (error: unknown) { - ctx.logger.warn( - `api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`, - ) - } - return ok(request, { selected: { ...selected } }) - } catch (error: unknown) { - return err(request, { - code: 'model-unavailable', - message: error instanceof Error ? error.message : String(error), - details: { provider, model }, - }) - } - }) - }, - - async rename(request) { - const { sessionId, title } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const titles = ctx.get('sessionTitle') - if (titles === undefined) { - return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} }) - } - try { - const accepted = titles.rename(found.agent.session, title) - return ok(request, { title: accepted.title, seq: accepted.eventSeq }) - } catch (error: unknown) { - // Only the input's fault maps to title-invalid (the message is - // product-user-visible in the rename dialog); liveness and disposal - // races are deployment trouble, not a bad title. - if (error instanceof SessionTitleInvalidError) { - return err(request, { - code: 'title-invalid', - message: error.message, - details: { sessionId }, - }) - } - return err(request, { - code: 'internal', - message: `failed to rename session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - }, - - async fork(request) { - const { sessionId, atSeq } = request.payload - let source: SessionReadState - try { - source = await readSessionState(sessionId) - } catch (error: unknown) { - if (error instanceof SessionNotFound) { - return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } }) - } - return err(request, { - code: 'internal', - message: `fork source unavailable for session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - const events = source.events - // An in-log anchor belongs to the turn containing it and must never - // clip backward to an earlier completed turn. Omitted and past-end - // anchors retain the last-completed-turn shortcut. - const lastSeq = events.at(-1)?.seq ?? -1 - const anchoredBoundary = atSeq === undefined - ? undefined - : events.find(e => e.type === 'turn/end' && e.seq >= atSeq) - const boundary = anchoredBoundary - ?? (atSeq === undefined || atSeq > lastSeq - ? events.findLast(e => e.type === 'turn/end') - : undefined) - if (boundary === undefined) { - return err(request, { - code: 'fork-unavailable', - message: atSeq !== undefined && atSeq <= lastSeq - ? `session "${sessionId}" has not completed the turn containing event ${String(atSeq)}` - : `session "${sessionId}" has no completed turn to fork from`, - details: { sessionId }, - }) - } - // Extend the cut through trailing out-of-band appends (session/title, - // injections) up to the next turn/start: they are standalone events, so - // the seed stays balanced, and the child inherits a title generated - // right after the boundary turn. - let cut = boundary.seq + 1 - while (cut < events.length && events[cut]?.type !== 'turn/start') cut++ - let workspace: Workspace | undefined - try { - workspace = await forkWorkspace(source) - } catch (error: unknown) { - return err(request, { - code: 'internal', - message: `failed to resolve fork workspace for session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - const childId = `session-${randomUUID()}` as SessionId - // The child inherits the parent's composition for the same reason a - // resumed session keeps its own: the seeded history was produced under - // those tools, and composing anything else would strand the tool calls - // it already carries. No model-facing row sits in the host plane, so - // composing nothing would leave the child with no tools at all. - const forkComposition = await composeAgent(resolveSessionPreset(source)) - try { - await ctx.agents.create({ - sessionId: childId, - seed: events.slice(0, cut), - meta: { - ...source.header.cwd === undefined ? {} : { cwd: source.header.cwd }, - parentSession: source.id, - seedLength: cut, - ...forkComposition.agentPreset === undefined - ? {} - : { agentPreset: forkComposition.agentPreset }, - }, - agentOptions: agentOptions(), - setup: forkComposition.setup, - }) - } catch (error: unknown) { - return err(request, { - code: 'internal', - message: `failed to fork session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - // An ordinary source keeps its direct Workspace. A subagent source is - // not listed there, so its ordinary fork joins the nearest owning - // ancestor instead. The child is already published if attach fails. - if (workspace !== undefined) { - try { - await workspace.attachSession(childId) - } catch (error: unknown) { - return err(request, { - code: 'workspace-attach-failed', - message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`, - details: { sessionId: childId, workspaceId: workspace.id }, - }) - } - } - return ok(request, { sessionId: childId }) - }, - - async prompt(request) { - const { sessionId, mode, content, clientTimeZone } = request.payload - const canonicalTimeZone = clientTimeZone === undefined - ? undefined - : canonicalClientTimeZone(clientTimeZone) - if (clientTimeZone !== undefined && canonicalTimeZone === undefined) { - return err(request, { - code: 'invalid-time-zone', - message: 'clientTimeZone must be UTC or a valid IANA Area/Location name', - details: { value: clientTimeZone }, - }) - } - const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) - if ('refused' in resolved) return resolved.refused - const agent = resolved.agent - // Request identity and optional browser zone ride the exact durable user message. - const source: MessageSource = { - kind: 'user', - rpcId: request.rpcId, - ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), - } - const hasImage = content.some(part => part.type === 'image') - const admit = async (): Promise> => { - try { - if (hasImage) { - const current = selectionFor(agent).current - const modelInfo = await ctx.llm.resolveModelInfo(current.provider, current.model) - if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) { - return err(request, { - code: 'attachment-error', - message: `Model "${current.model}" does not support image input.`, - details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, - }) - } - } - const durable = await durablePromptContent(ctx, content) - const message: UserMessage = createUserMessage({ content: durable, source }) - if (mode === 'steer') agent.steer(message) - else agent.followup(message) - } catch (error: unknown) { - if (error instanceof AttachmentError) { - return err(request, { - code: 'attachment-error', - message: error.message, - details: { reason: error.code }, - }) - } - return err(request, { - code: 'agent-busy', - message: 'prompt rejected', - details: { reason: String(error) }, - }) - } - return ok(request, { accepted: true as const }) - } - return hasImage ? serializeImageAdmission(agent, admit) : admit() - }, - - async attachment(request) { - const { sessionId, attachmentId } = request.payload - let state: SessionReadState - try { - state = await readSessionState(sessionId) - } catch (error: unknown) { - if (error instanceof SessionNotFound) { - return err(request, { - code: 'session-not-found', - message: error.message, - details: { sessionId }, - }) - } - return err(request, { - code: 'internal', - message: `attachment authorization unavailable for session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - const ref = referencedImage(state.events, String(attachmentId)) - if (ref === undefined) { - return err(request, { - code: 'attachment-error', - message: 'Image is not referenced by this session.', - details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, - }) - } - try { - const stored = await ctx.attachments.readImage(ref) - return ok(request, { - attachment: stored.ref, - data: Buffer.from(stored.data).toString('base64'), - }) - } catch (error: unknown) { - if (error instanceof AttachmentError) { - return err(request, { - code: 'attachment-error', - message: error.message, - details: { reason: error.code }, - }) - } - return err(request, { - code: 'internal', - message: 'Unable to read image attachment.', - details: {}, - }) - } - }, - - updateQueue(request) { - const { sessionId, itemId, action } = request.payload - if (action.kind === 'edit' && action.content.some(block => block.type !== 'text')) { - return Promise.resolve(err(request, { - code: 'attachment-error', - message: 'queue edits accept text content only', - details: { reason: 'QUEUE_EDIT_NON_TEXT' }, - })) - } - const agent = ctx.agents.get(sessionId) - if (agent !== undefined && hasSubagentOwner(agent.session, agent)) { - return Promise.resolve(err(request, subagentOwnershipError(sessionId))) - } - if (agent === undefined) { - return Promise.resolve(err(request, { - code: 'queue-item-not-found', - message: 'queued item is no longer pending', - details: { itemId }, - })) - } - const target = agent.inbox.nextTurn.some(message => message.id === itemId) - ? 'next-turn' - : agent.inbox.nextStep.some(message => message.id === itemId) ? 'next-step' : undefined - const message = target === undefined - ? undefined - : (target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep) - .find(candidate => candidate.id === itemId) - if (target === undefined || message === undefined) { - return Promise.resolve(err(request, { - code: 'queue-item-not-found', - message: 'queued item is no longer pending', - details: { itemId }, - })) - } - if (action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) { - return Promise.resolve(err(request, { - code: 'steer-unavailable', - message: 'current turn no longer accepts steering', - details: { itemId }, - })) - } - if (action.kind === 'edit') { - agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content })) - } else { - agent.inbox.remove(itemId) - if (action.kind === 'steer') agent.steer(message) - } - return Promise.resolve(ok(request, { accepted: true as const })) - }, - - cancel(request) { - const { sessionId } = request.payload - const agent = ctx.agents.get(sessionId) - if (agent === undefined) { - return Promise.resolve(err(request, { - code: 'session-not-found', - message: `session "${sessionId}" not found (not attached)`, - details: { sessionId }, - })) - } - if (hasSubagentOwner(agent.session, agent)) { - return Promise.resolve(err(request, subagentOwnershipError(sessionId))) - } - agent.cancel({ kind: 'user' }, { keepInbox: true }) - return Promise.resolve(ok(request, { accepted: true as const })) - }, - }, - subagents: { async list(request, signal) { try { @@ -2565,75 +516,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, - async history(request, signal) { - const { - parentSessionId, childSessionId, mode, beforeSeq, maxMessages, - } = request.payload - const verified = await catalogChild(ctx, { - parentSessionId, childSessionId, mode, - }, signal) - if (verified.error !== undefined) return err(request, verified.error) - // The generic-history data plane: an attached child serves its - // in-memory snapshot and the registry's live watermark projections; a - // cold child is one persistence inspection plus a detached fold. - let header: SessionHeader - let events: SessionEvent[] - let projections: SessionProjectionsBlock | undefined - const attached = ctx.sessions.get(childSessionId) - if (attached !== undefined) { - header = attached.header - events = [...attached.events] - projections = beforeSeq === undefined - ? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached)) - : undefined - } else { - try { - const inspected = await inspectServable(childSessionId) - header = inspected.meta - events = inspected.events - projections = beforeSeq === undefined - ? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events)) - : undefined - } catch (error: unknown) { - if (signal?.aborted) { - return err(request, { - code: 'cancelled', - message: 'subagent history read was cancelled', - details: {}, - }) - } - if (error instanceof SessionNotFound) { - return err(request, { - code: 'subagent-not-found', - message: 'subagent disappeared during history read', - details: { parentSessionId, childSessionId }, - }) - } - return err(request, { - code: 'internal', - message: 'subagent history read failed', - details: {}, - }) - } - } - if (signal?.aborted) { - return err(request, { - code: 'cancelled', - message: 'subagent history read was cancelled', - details: {}, - }) - } - if (header.parentSession !== parentSessionId) { - return err(request, { - code: 'subagent-unauthorized', - message: 'subagent parent changed during history read', - details: { childSessionId }, - }) - } - const page = historyPage(ctx, events, beforeSeq, maxMessages) - return ok(request, { ...page, ...projections === undefined ? {} : { projections } }) - }, - async prompt(request, signal) { const { parentSessionId, childSessionId, content, clientTimeZone } = request.payload const canonicalTimeZone = clientTimeZone === undefined @@ -2662,7 +544,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const messageId = await ctx.subagents.followup(parent, childSessionId, content, { source: { kind: 'user', - rpcId: request.rpcId, + rpcId: request.rpcId as unknown as SessionRequestId, ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), }, signal, @@ -2699,135 +581,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, - workspace: { - list(request) { - return Promise.resolve(ok(request, { - items: ctx.workspaceRegistry.list().map(workspaceView), - archivedSessionIds: [...ctx.workspaceRegistry.archivedSessionIds], - })) - }, - - async create(request) { - const { path } = request.payload - try { - const { workspace, created } = await ensureWorkspace(path) - return ok(request, { workspace: workspaceView(workspace), created }) - } catch (error: unknown) { - // The registry rejects a path that does not resolve to an existing - // directory (realpath ENOENT / not-a-directory) — the business - // error of the typed-path flow, surfaced as a validation failure. - return err(request, { - code: 'workspace-invalid-path', - message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`, - details: { path }, - }) - } - }, - - async rename(request) { - const { payload } = request - const workspace = ctx.workspaceRegistry.get(brandWorkspaceId(payload.workspaceId)) - if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) - const title = payload.title.trim() - // Uniqueness AND the same-title no-op both ride the create chain so - // they observe the state left by earlier queued renames — checked - // up front, a queued A→A could report success while an earlier A→B - // still lands afterwards. - const operation = workspaceCreationChain.then(async () => { - if (title === workspace.title) return - if (ctx.workspaceRegistry.list().some(other => other.id !== workspace.id && other.title === title)) { - throw new WorkspaceNameConflictError(title) - } - await workspace.setTitle(title) - }) - workspaceCreationChain = operation.then(() => undefined, () => undefined) - try { - await operation - } catch (error: unknown) { - if (error instanceof WorkspaceNameConflictError) { - return err(request, { - code: 'workspace-name-conflict', - message: error.message, - details: { name: error.workspaceName }, - }) - } - throw error - } - return ok(request, { workspace: workspaceView(workspace) }) - }, - - async delete(request) { - const { workspaceId } = request.payload - const operation = workspaceCreationChain.then(() => - ctx.workspaceRegistry.delete(brandWorkspaceId(workspaceId))) - workspaceCreationChain = operation.then(() => undefined, () => undefined) - if (!await operation) return workspaceNotFound(request, workspaceId) - return ok(request, { deleted: true as const }) - }, - - async insertBefore(request) { - const { workspaceId, beforeWorkspaceId } = request.payload - try { - const workspaceIds = await ctx.workspaceRegistry.insertBefore( - brandWorkspaceId(workspaceId), - beforeWorkspaceId === undefined ? undefined : brandWorkspaceId(beforeWorkspaceId), - ) - return ok(request, { workspaceIds: [...workspaceIds] }) - } catch (error: unknown) { - if (!(error instanceof WorkspaceOrderInvalidError)) throw error - return workspaceNotFound(request, error.workspaceId) - } - }, - - async insertSessionBefore(request) { - const { payload } = request - const workspace = ctx.workspaceRegistry.get(brandWorkspaceId(payload.workspaceId)) - if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) - try { - await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId) - } catch (error: unknown) { - // Only the entity's unaccounted-id rejection is the business code; - // storage/durability failures propagate as internal errors. - if (!(error instanceof WorkspaceMoveInvalidError)) throw error - return err(request, { - code: 'workspace-move-invalid', - message: error.message, - details: { - workspaceId: payload.workspaceId, - sessionId: payload.sessionId, - ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId }, - }, - }) - } - return ok(request, { workspace: workspaceView(workspace) }) - }, - - async archiveSession(request) { - const { sessionId } = request.payload - try { - await ctx.workspaceRegistry.archiveSession(sessionId) - } catch (error: unknown) { - // Only the registry's unknown-session rejection is the business - // code; storage/durability failures propagate as internal errors. - if (!(error instanceof WorkspaceUnknownSessionError)) throw error - return err(request, { - code: 'session-not-found', - message: error.message, - details: { sessionId }, - }) - } - return ok(request, { archivedSessionIds: [...ctx.workspaceRegistry.archivedSessionIds] }) - }, - }, - host: { describe(request) { // TODO(apiproxy-version): read the version from apps/cli/package.json. const selection = defaults.defaultModelSelection() return Promise.resolve(ok(request, { version: '0.0.1', - // Same source as session.create's fallback: the UI's default project - // must match where an unspecified-cwd session actually lands. + // This must match the default cwd supplied to Session Controller so + // the UI's project hint names where a cwd-less create request lands. cwd: defaults.cwd, // Read live for the same reason: this is what the NEXT session will // start from, so a saved default has to be what it reports. @@ -2881,8 +642,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // stops the backend's directory scan instead of outliving it. return ok(request, await capability.list(request.payload.path, signal)) } catch (error: unknown) { - // An abort is the caller's own timeout/disconnect, not a server - // failure — same code pickDirectory and command.execute report. + // An abort is the caller's own timeout/disconnect, not a server failure. if (signal.aborted) { return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} }) } @@ -3109,12 +869,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // the view scope is the live agent or the preset's standing key. async list(request) { const { sessionId } = request.payload - const session = ctx.sessions.get(sessionId) - if (session === undefined) { + let session: PresetBearingSession + try { + const inspected = await ctx.sessionController.inspect(sessionId) + session = { header: inspected.meta, events: inspected.events } + } catch (error: unknown) { + if (error instanceof ApiSessionNotFound) { + return err(request, { + code: 'session-not-found', + message: error.message, + details: { sessionId }, + }) + } return err(request, { - code: 'session-not-found', - message: `session "${sessionId}" not found (not attached)`, - details: { sessionId }, + code: 'internal', + message: `session "${sessionId}" could not be inspected: ${String(error)}`, + details: {}, }) } if (session.header.cwd === undefined) { @@ -3130,18 +900,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const live = ctx.agents.get(sessionId) const presets = ctx.get('agentPresets') const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') - // Same stance as the commands domain: a missing service means no - // composition mounts dsh-skill, not an empty catalog. `ctx.get` also + // A missing service means no composition mounts dsh-skill, not an + // empty catalog. `ctx.get` also // keeps this handler independent of the gateway plugin's inject list // (an undeclared `ctx.skills` property read fails the reflect proxy). const skillRegistry = scoped ?? ctx.get('skills') if (skillRegistry === undefined) { return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} }) } - // The scope presenters resolve in — the live agent, else the recorded - // preset's standing key, else the global layer — so a cold session's - // '/' popup lists the catalog its composition actually serves. - const scope = await presenterScopeFor(sessionId, session) + // Resolve the live or recorded preset scope so the catalog matches the + // Session composition without resuming its Agent. + const scope = await sessionScopeFor(sessionId, session) try { const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) return ok(request, { @@ -3324,216 +1093,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, - events: { - mux(_request, signal) { - const queue = new FrameQueue>() - muxQueues.add(queue) - for (const session of ctx.sessions.list()) { - subscribeSession(queue, session) - } - for (const pending of pendingQuestions.values()) { - queue.push({ - rpcId: pending.rpcId, - payload: { - type: 'question/requested', sessionId: pending.sessionId, - questions: pending.questions, - }, - }) - } - // Refresh recovery: still-pending approval questions replay with their - // stable rpcId so a reconnecting client can still answer them. - for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending)) - // Queue snapshot baseline (pendingQuestions precedent): frames replayed - // in arrival order per session; a reconnecting client rebuilds its - // queue view from these alone. - for (const session of ctx.sessions.list()) { - const agent = ctx.agents.get(session.id) - if (agent?.session === session && agent.inbox.hasPending) { - queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) })) - } - } - // Background-task baseline. `ctx.agents.get` is the non-resuming read: - // a session with no live Agent owns no tasks, so it correctly sees only - // the unowned ones, and listing never revives a cold session. An empty - // set sends nothing — absence is how the client reads "no tasks". - const jobs = ctx.get('jobs') - if (jobs !== undefined) { - for (const session of ctx.sessions.list()) { - const views = jobViews(jobs.list(ctx.agents.get(session.id))) - if (views.length > 0) { - queue.push(frame({ type: 'session/jobs', sessionId: session.id, jobs: views })) - } - } - } - // Per-session open-call table for result-view pairing. Bounded by the - // per-turn call count: entries clear on turn/end; a table miss (stream - // opened mid-turn) backscans the session's in-memory events instead. - const openCalls = new Map>() - const disposers = [ - ctx.on('session/event', (session: Session, event: SessionEvent) => { - if (event.type === 'tool/call') { - const data = event.data as ToolCallData - try { - let table = openCalls.get(session.id) - if (table === undefined) openCalls.set(session.id, table = new Map()) - table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) }) - } catch { - // Unparseable model arguments: leave the table unset; the result view soft-falls. - } - } else if (event.type === 'turn/end') { - openCalls.delete(session.id) - } - const view = viewFor( - ctx, event, - callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId), - ctx.agents.get(session.id), - ) - queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } })) - }), - ctx.on('session/created', (session: Session) => { - subscribeSession(queue, session) - // The subscribe frame clears the client's task mirror, and a - // session born after the stream opened missed the baseline loop. - // Unowned tasks are visible to it from birth, so without this it - // would show none until the next registry change. - const views = jobs === undefined ? [] : jobViews(jobs.list(ctx.agents.get(session.id))) - if (views.length > 0) { - queue.push(frame({ type: 'session/jobs', sessionId: session.id, jobs: views })) - } - }), - ctx.on('session/disposed', (session: Session) => { - openCalls.delete(session.id) - }), - ...jobs === undefined ? [] : [jobs.onJobsChanged((owner) => { - if (owner !== undefined) { - // The exact owner instance the fence compares against, so the - // push stays correct even while that Agent's scope is tearing - // down and a lookup by id would already miss. - queue.push(frame({ type: 'session/jobs', sessionId: owner.id, jobs: jobViews(jobs.list(owner)) })) - return - } - // An unowned task is visible to every caller, so every subscribed - // session's set changed with it. - for (const session of ctx.sessions.list()) { - queue.push(frame({ - type: 'session/jobs', - sessionId: session.id, - jobs: jobViews(jobs.list(ctx.agents.get(session.id))), - })) - } - })], - ] - return queue.iterate(signal, () => { - muxQueues.delete(queue) - for (const dispose of disposers) dispose() - }) - }, - - host(_request, signal) { - const queue = new FrameQueue>() - const committedWorkspaces = ctx.workspaceRegistry.list() - const committedWorkspaceIds = new Set( - committedWorkspaces.map(workspace => String(workspace.id)), - ) - let committedWorkspaceOrder = committedWorkspaces.map(workspace => workspace.id) - // Frame-dedup baseline, same posture as committedWorkspaceIds: the - // stream opens against the current set; workspace.list re-baselines - // reconnecting clients, so only later changes need frames. - let archivedSessionIds = ctx.workspaceRegistry.archivedSessionIds - const disposers = [ - ctx.on('session/created', (session: Session) => { - queue.push(frame({ - type: 'host/session-added', - sessionId: session.id, - // Derived at frame time like summarize(); a just-created session - // has run no turn yet, so this is constantly true in practice. - blank: sessionBlank(session), - // Including cwd lets the client group the new session without refreshing the list. - ...sessionListFields(session.header, session.events), - })) - }), - ctx.on('session/disposed', (session: Session) => { - queue.push(frame({ type: 'host/session-removed', sessionId: session.id })) - }), - ctx.on('agent/status', ({ agent, status }: { agent: Agent; status: AgentStatus }) => { - queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' })) - }), - ctx.on('agent/error', ({ agent, error }: { agent: Agent; error: unknown }) => { - queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) })) - }), - ctx.on('domain/changed', (change) => { - if (change.domain !== 'workspace') return - if (change.table === '') { - if (change.operation !== 'put') return - const state = workspaceDomainState.parse(change.value) - const orderChanged = state.workspaceIds.length === committedWorkspaceOrder.length - && state.workspaceIds.every(workspaceId => committedWorkspaceIds.has(String(workspaceId))) - && state.workspaceIds.some((workspaceId, index) => workspaceId !== committedWorkspaceOrder[index]) - for (const workspaceId of state.workspaceIds) { - if (committedWorkspaceIds.has(workspaceId)) continue - const workspace = ctx.workspaceRegistry.get(workspaceId) - if (workspace === undefined) { - throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`) - } - committedWorkspaceIds.add(workspaceId) - queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) - } - committedWorkspaceOrder = [...state.workspaceIds] - if (orderChanged) { - queue.push(frame({ - type: 'host/workspace-order-changed', - workspaceIds: [...state.workspaceIds], - })) - } - if (state.archivedSessionIds.length !== archivedSessionIds.length - || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { - archivedSessionIds = state.archivedSessionIds - queue.push(frame({ - type: 'host/archived-sessions-changed', - archivedSessionIds: [...state.archivedSessionIds], - })) - } - return - } - if (change.table !== 'workspaces') return - if (change.operation === 'deleted') { - if (!committedWorkspaceIds.delete(change.key)) return - queue.push(frame({ - type: 'host/workspace-removed', - workspaceId: change.key as WorkspaceId, - })) - return - } - if (!committedWorkspaceIds.has(change.key)) return - // Existing-entity table writes are complete attach/touch commits. - // A new entity's first put waits for the global registry write above. - queue.push(frame({ - type: 'host/workspace-changed', - workspace: changedWorkspaceView(change.key, change.value), - })) - }), - // Allowlisted host events ride one verbatim wrapper frame each. The - // allowlist is api-remotes', and `ctx.remote.$on` is the consumer - // face; nothing here projects, redacts, or renames. - ...API_REMOTE_FORWARDED_EVENTS.map(name => ctx.on( - name, - // The allowlist's shape assertion proves each name is a real, - // non-scoped, void-returning event, so the rest-parameter handler - // satisfies every member of the union `on` accepts here; - // assertJsonArgs proves the payload is JSON-safe before it queues. - ((...args: unknown[]) => { - queue.push(frame({ - type: 'host/remote-event', - event: name, - args: assertJsonArgs(name, args), - })) - }), - )), - ] - return queue.iterate(signal, () => { for (const dispose of disposers) dispose() }) - }, - }, - downloads: { async sessionLog(request, signal) { // Clean error path first: missing services answer 500 and a missing @@ -3590,53 +1149,5 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ) }, }, - - respond(message: ClientResponse): Promise { - // Route by the echoed rpcId (the wire correlation): approvals first, - // then questions — the two registries share one id space of UUIDs. - const approval = pendingApprovals.get(message.rpcId) - if (approval !== undefined) { - if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) - const parsed = approvalResponsePayloadSchema.safeParse(message.result.value) - // The payload's audit correlation must match the entry the rpcId routed - // to — a mismatched answer is malformed, not merely late. - if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) { - return Promise.resolve({ accepted: false, reason: 'bad-response' }) - } - approval.resolve(parsed.data.outcome) - return Promise.resolve({ accepted: true }) - } - const pending = pendingQuestions.get(message.rpcId) - if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' }) - if (!message.result.ok) { - if (message.result.error.code !== 'cancelled') { - return Promise.resolve({ accepted: false, reason: 'bad-response' }) - } - claimQuestion(pending, 'cancelled') - pending.reject(new UserQuestionError( - 'the user cancelled ask_user_question', 'ASK_CANCELLED')) - return Promise.resolve({ accepted: true }) - } - const parsed = questionResponsePayloadSchema.safeParse(message.result.value) - if (!parsed.success) { - return Promise.resolve({ accepted: false, reason: 'bad-response' }) - } - const payload: QuestionResponsePayload = { - sessionId: parsed.data.sessionId, - answer: { - answers: parsed.data.answer.answers.map(answer => ({ - id: answer.id, - selected: answer.selected, - ...(answer.custom === undefined ? {} : { custom: answer.custom }), - })), - }, - } - if (!matchesQuestions(payload, pending)) { - return Promise.resolve({ accepted: false, reason: 'bad-response' }) - } - claimQuestion(pending, 'answered') - pending.resolve(payload.answer) - return Promise.resolve({ accepted: true }) - }, } } diff --git a/packages/host/apiproxy/src/api/agent-presets.schema.ts b/packages/host/apiproxy/src/api/agent-presets.schema.ts index da3da6f9be..36a4a5b9c8 100644 --- a/packages/host/apiproxy/src/api/agent-presets.schema.ts +++ b/packages/host/apiproxy/src/api/agent-presets.schema.ts @@ -6,7 +6,7 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './sessions.schema.ts' +import { sessionIdSchema } from './ids.schema.ts' import type { AgentPresetEntry } from './agent-presets.ts' /** AgentPresetEntry row of agentPreset.list. */ diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts index 778225c0cc..cd95cfbd45 100644 --- a/packages/host/apiproxy/src/api/downloads.schema.ts +++ b/packages/host/apiproxy/src/api/downloads.schema.ts @@ -2,13 +2,12 @@ * downloads domain zod schemas. The download surface has no wire * envelope: the request arrives as query parameters (all strings), so its * request schema parses the raw query-parameter object into the method's - * exact request shape. SessionId brand cast point: sessionIdSchema, and only - * there (hosted in sessions.schema like every other cast). + * exact request shape. */ import { z } from 'zod' import type { DownloadsApi } from './downloads.ts' -import { sessionIdSchema } from './sessions.schema.ts' +import { sessionIdSchema } from './ids.schema.ts' /** * session.export query params → the sessionLog request. `includeDescendants` diff --git a/packages/host/apiproxy/src/api/downloads.ts b/packages/host/apiproxy/src/api/downloads.ts index d0e6138b6a..fc8728e497 100644 --- a/packages/host/apiproxy/src/api/downloads.ts +++ b/packages/host/apiproxy/src/api/downloads.ts @@ -1,7 +1,6 @@ /** - * downloads domain contract: host-only download surfaces — the GET-download - * channel family, the mirror of the SSE-stream `events` domain. No wire - * envelope: the carrier's GET routes answer these directly, and the browser + * downloads domain contract: Host-only GET download surfaces with no wire + * envelope. Carrier routes answer these directly, and the browser * `IApiClient` never exposes them. */ diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts deleted file mode 100644 index d4e66c8948..0000000000 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * events domain zod schemas: MuxFrame / HostFrame unions (discriminatedUnion('type')). - * A frame is the payload slot of the ServerRequest full form; the SessionEvent inside - * a session/event frame reuses sessions.schema's strict-envelope + wide-data passthrough branch. - */ - -import { z } from 'zod' -import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types' -import type { HostFrame, MuxFrame } from './events.ts' -import type { Wire } from './rpc.schema.ts' -import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts' -import { approvalRequestIdSchema } from './approvals.schema.ts' -import { - contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema, -} from './sessions.schema.ts' -import { taskViewSchema } from './jobs.schema.ts' -import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' - -/** Question fields validated strictly against core dsh-user-questions. */ -export const askUserQuestionItemSchema = z.object({ - id: z.string(), - question: z.string(), - header: z.string().optional(), - detail: z.string().optional(), - options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(), - multiSelect: z.boolean().optional(), - // Presentation intent: a tagged union on the wire, so an unknown tag is a - // rejected frame rather than a silently generic render. - intent: z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('plan-review'), approve: z.string() }), - ]).optional(), -}) satisfies z.ZodType> - -/** Unified message envelope carried by transient queue frames. */ -const messageSchema = z.object({ - id: z.string().min(1), - role: z.union([z.literal('system'), z.literal('user'), z.literal('assistant')]), - content: z.array(contentBlockSchema), - source: z.looseObject({ kind: z.string() }), -}) - -/** MuxFrame union (payload slot of a mux-stream ServerRequest). */ -export const muxFrameSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }), - z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }), - z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }), - z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }), - // Non-empty by wire contract: the user-questions service rejects empty - // batches at ask() (EMPTY_QUESTIONS), so an empty frame is host breakage - // and must fail loud here, not reach the composer. - z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }), - z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }), - z.object({ - type: z.literal('session/queue'), - sessionId: sessionIdSchema, - items: z.array(z.object({ - id: messageIdSchema, - placement: z.union([z.literal('queued'), z.literal('steering'), z.literal('context')]), - message: messageSchema, - })), - }), - z.object({ type: z.literal('session/jobs'), sessionId: sessionIdSchema, jobs: z.array(taskViewSchema) }), - // value stays wide: it already passed its unit's own schema on the host, - // and deep-validating here would import every domain's schema into the carrier. - z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }), - z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }), -]) as unknown as z.ZodType - -/** HostFrame union (payload slot of a host-stream ServerRequest). */ -export const hostFrameSchema = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('host/session-added'), - sessionId: sessionIdSchema, - blank: z.boolean(), - parentSessionId: sessionIdSchema.optional(), - origin: z.literal('subagent').optional(), - cwd: z.string().optional(), - agentPreset: z.string().optional(), - }), - z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }), - z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }), - z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), - z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), - z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), - z.object({ type: z.literal('host/workspace-order-changed'), workspaceIds: z.array(workspaceIdSchema) }), - z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), - // args stays wide, the same posture as session/projection's value: the frame - // arrives from JSON.parse, so every element is already a JSON value, and the - // structural contract belongs to the owner package's cordis `Events` - // declaration — the host validated JSON-safety before forwarding. - z.object({ type: z.literal('host/remote-event'), event: z.string().min(1), args: z.array(z.unknown()) }), - z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }), -]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts deleted file mode 100644 index 0ff088745f..0000000000 --- a/packages/host/apiproxy/src/api/events.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * events domain contract: signatures and frame unions for the two logical - * streams. Four-quadrant: streams yield the narrow form `RpcRequest` (server-request - * view) — rpcId must be exposed to the business layer, because responses to answerable frames - * (approval/question requested) echo it; for pure pushes it identifies that one push. - * signal is a local stream-control parameter, independent of the request (never on the wire). - */ - -import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types' -import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types' -import type { Message } from '@deepseek-ai/dsh-llm/types' -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { CallId } from '@deepseek-ai/dsh-llm/brand' -import type { JsonValue, SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' -import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' -import type { RpcError, RpcId, RpcRequest } from './rpc.ts' -import type { JobView } from './jobs.ts' -import type { WorkspaceView } from './workspace.ts' - -// Client-side consumers take the render-intent vocabulary from the contract; -// dsh-tools remains its owner. -export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' - -/** - * Host-computed render intent accompanying a `tool/call` or `tool/result` - * event. A pure derivation of args/result through the presenter registered at - * emission time — never persisted (the session log carries only the event), so - * the same event may carry a different view (or none) on a later delivery. - * `for` names which vocabulary applies without re-inspecting the event type. - * An absent view means the client's documented default (generic JSON card). - */ -export type ToolEventView = - | { for: 'call'; view: ToolCallView } - | { for: 'result'; view: ToolResultView } - -/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */ -export interface QueuedInboxItem { - /** Message identity used by inbox mutations. */ - id: MessageId - /** Agent-resolved FIFO placement; queued and steering items render on different surfaces, context items stay invisible until claimed. */ - placement: 'queued' | 'steering' | 'context' - /** Complete pending message; it is not durable until the Agent claims it. */ - message: Message -} - -/** Streaming face of the contract: the two logical stream openers (mux + host). */ -export interface EventsApi { - /** - * All-session aggregated mux stream. On open, emits a subscribed control frame for every - * attached session, then replays each session's still-pending approval/question requested - * frames (rpcId reused verbatim — the refresh-recovery baseline). Session titles ride the - * generic projection pair (history-tail projections block + session/projection frames). - * since: resume hook, unimplemented in v1 (ignored if passed); reconnection = reopen the - * stream + refetch history. - */ - mux(request: RpcRequest<{ since?: Record }>, signal: AbortSignal): AsyncIterable> - - /** - * Host-level info stream: session create/destroy, running-status flips, and - * agent failures with no turn position. Empty payload uses `{}`. - */ - host(request: RpcRequest<{}>, signal: AbortSignal): AsyncIterable> -} - -/** - * Mux stream frames: raw session-event passthrough + control frames + - * approval/question frames (requested = answerable server-request, the rest are pure pushes). - */ -export type MuxFrame = - | { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView } - | { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number } - | { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string } - | { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome } - | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] } - | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' } - /** - * Complete transient inbox state after every enqueue, mutation, claim, or - * discard. Pending work is not model-visible and therefore has no durable - * session event; the whole snapshot makes edit, deletion, cancel, and - * reconnect converge through one authoritative signal. `session/queue` - * covers both resolved placements: queued items render - * in QueueDock, while pending steering renders at the conversation tail. - */ - | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] } - /** - * Complete set of background jobs this session can see, after every registry - * commit that changes it: registration, the stopping transition, settlement, - * and owner-disposal removal. The registry is process-local and holds no - * durable event, so — exactly like `session/queue` — the whole snapshot is - * what makes a start, a kill, a reconnect, and a second tab converge on one - * authoritative value. - * - * Sent as a subscription baseline only for a session that currently has - * tasks; an absent key means an empty set. A change that empties the set - * still sends `[]`, since that transition is the only one absence cannot - * express. - */ - | { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] } - /** - * One projection unit's finished value changed (session-projection RFC). - * Live push state, never logged — replay recomputes on the host (the - * tool-view posture). `value` is the unit's schema-validated view output; - * `seq` is the unit's watermark at emission. Clients keep one generic - * per-session value store under higher-seq-wins, seeded by the history - * tail page's projections block. - */ - | { type: 'session/projection'; sessionId: SessionId; key: string; value: unknown; seq: number } - | { type: 'stream/error'; error: RpcError } - -/** - * Host stream frames. session-added carries the lineage anchor, product - * origin, project cwd, and blank bit (the list-summary fields a client cannot - * wait for a refresh to learn); the frame fires at session/created, so blank is - * constantly true — clients flip it on the session's first - * `host/session-status(running:true)` (a blank session never runs), and a - * reconnecting client takes `session.list`'s summary.blank as authoritative. - * agent-error is the only outlet for live failures with no turn position; - * workspace-changed pushes the full new snapshot after every durable - * workspace mutation (create/attach/order change — the client upserts, while - * `workspace.list` provides the reconnect baseline); workspace-removed is the - * committed registration-deletion increment and never implies directory or - * session-log deletion; workspace-order-changed pushes the complete durable - * registry order after a reorder; archived-sessions-changed pushes the full registry - * archive set after every durable change (same full-snapshot posture as - * workspace-changed — `workspace.list` re-baselines it on reconnect). - */ -export type HostFrame = - | { - type: 'host/session-added' - sessionId: SessionId - blank: boolean - parentSessionId?: SessionId - origin?: 'subagent' - cwd?: string - agentPreset?: string - } - | { type: 'host/session-removed'; sessionId: SessionId } - | { type: 'host/session-status'; sessionId: SessionId; running: boolean } - | { type: 'host/agent-error'; sessionId: SessionId; message: string } - | { type: 'host/workspace-changed'; workspace: WorkspaceView } - | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } - | { type: 'host/workspace-order-changed'; workspaceIds: WorkspaceView['workspaceId'][] } - | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } - /** - * One allowlisted host cordis event forwarded verbatim. The allowlist is - * owned by `@deepseek-ai/dsh-api-remotes` (`API_REMOTE_FORWARDED_EVENTS`), - * which is also the only control point over what a consumer can receive. - * `event` is the host's own event name and `args` its argument list: this - * path applies no projection, no redaction, and no renaming, so the payload - * contract is the owner package's cordis `Events` declaration rather than - * anything stated here. Delivery lands on `ctx.remote.$on`, not on a - * per-event frame variant. - */ - | { type: 'host/remote-event'; event: string; args: JsonValue[] } - | { type: 'stream/error'; error: RpcError } diff --git a/packages/host/apiproxy/src/api/goals.ts b/packages/host/apiproxy/src/api/goals.ts index 9ed65e9461..abf6e53a07 100644 --- a/packages/host/apiproxy/src/api/goals.ts +++ b/packages/host/apiproxy/src/api/goals.ts @@ -2,11 +2,10 @@ * goals domain contract. Method signatures are the source of truth: * unary methods take the RpcRequest

narrow form and the impl echoes rpcId. * - * Mutations only: the read side is the 'goal' session projection (history - * tail-page projections block + session/projection frames), so there is no - * goal.get and no wire goal view — responses acknowledge with the new CAS - * ref and never feed client state (the committed goal/change event reaches - * every client through the mux stream carrying the same whole value). + * Mutations only: the read side is the `goal` Session projection carried by + * Session Controller history and control streams. There is no goal.get or + * separate wire goal view; responses acknowledge with the new CAS ref, and + * committed goal/change events update the projection. */ import type { Branded } from '@deepseek-ai/dsh-brand' diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index b5e1d1ffd9..6fc92b71e4 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -1,81 +1,58 @@ /** * apiproxy contract-layer barrel. api/ has zero Node dependencies and is - * importable from the browser; the TS interfaces are the authoritative contract, while HTTP, - * WebSocket, and in-process SSE are merely physical channels (four-quadrant message model). + * importable from the browser; the TypeScript interfaces are authoritative, + * while HTTP supplies the carrier. */ -import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' -import type { WorkspaceApi } from './workspace.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SubagentsApi } from './subagents.ts' -import type { EventsApi } from './events.ts' import type { GoalsApi } from './goals.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' import type { DownloadsApi } from './downloads.ts' -import type { ClientResponse, RpcReceipt } from './rpc.ts' /** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */ export interface ApiProxy { - sessions: SessionsApi subagents: SubagentsApi host: HostApi - workspace: WorkspaceApi skills: SkillsApi agentPresets: AgentPresetsApi - events: EventsApi goals: GoalsApi settings: SettingsApi credentials: CredentialsApi llm: LlmApi /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ downloads: DownloadsApi - /** - * Response entry for server requests; not a domain method. - * @param message - Client response carrying the server request's rpcId. - * @returns Transport receipt for the response delivery. - */ - respond(message: ClientResponse): Promise } // ---- Domain interfaces and payload entities ---- export type { - HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, - SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary, -} from './sessions.ts' + ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelReasoningEffort, ModelSelection, +} from '@deepseek-ai/dsh-api-session-controller/types' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry, SubagentPromptReceipt, SubagentsApi, } from './subagents.ts' -export type { JobView } from './jobs.ts' -export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' -export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' export type { DownloadsApi } from './downloads.ts' -export type { ApprovalResponsePayload } from './approvals.ts' - -export type { QuestionResponsePayload } from './questions.ts' // ---- Message layer: narrow forms (domain-signature view) ---- export type { RpcRequest, RpcResponse } from './rpc.ts' -// ---- Message layer: the four wire full forms + carrier receipt ---- +// ---- Message layer: unary wire forms ---- export type { ClientRequest, - ClientResponse, RpcMessage, - RpcReceipt, - ServerRequest, ServerResponse, } from './rpc.ts' @@ -84,15 +61,8 @@ export { RpcId, transportError } from './rpc.ts' export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts' export { clientRequestSchema, - serverRequestSchema, serverResponseSchema, } from './rpc.schema.ts' -// ---- Fixed session-search product bounds ---- -export { - SESSION_SEARCH_RESULT_LIMIT, - SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, -} from './session-search.ts' - // ---- Method registry and derived generics ---- export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts' diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts index 7c8b0e6397..1fa7a6ceea 100644 --- a/packages/host/apiproxy/src/api/llm.schema.ts +++ b/packages/host/apiproxy/src/api/llm.schema.ts @@ -7,7 +7,48 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts' -import { modelCatalogFailureSchema, modelProviderGroupSchema } from './sessions.schema.ts' +import type { + ModelCatalogFailure, + ModelCatalogModel, + ModelProviderGroup, + ModelReasoning, + ModelReasoningEffort, +} from '@deepseek-ai/dsh-api-session-controller/types' + +/** One adapter-owned reasoning effort. */ +const modelReasoningEffortSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), +}) satisfies z.ZodType> + +/** Exact-model reasoning metadata. */ +const modelReasoningSchema = z.object({ + efforts: z.array(modelReasoningEffortSchema).min(1), + defaultEffort: z.string().min(1).optional(), +}) satisfies z.ZodType> + +/** One advisory model entry inside a provider group. */ +const modelCatalogModelSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + reasoning: modelReasoningSchema.optional(), +}) satisfies z.ZodType> + +/** One successfully loaded provider group. */ +const modelProviderGroupSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + models: z.array(modelCatalogModelSchema), +}) satisfies z.ZodType> + +/** One provider-local catalog failure. */ +const modelCatalogFailureSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + message: z.string(), +}) satisfies z.ZodType> /** ConfigurableProviderView row of llm.providers. */ export const configurableProviderViewSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index e9a5b52e72..9aef65d856 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -2,14 +2,16 @@ * llm domain contract: host-scoped provider topology for configuration * surfaces. `llm.providers` merges the configurable-provider directory * (which providers CAN be configured, and where their settings live) with the - * live route registry; `llm.models` is the session-independent model catalog - * (the same groups as `session.models`, without a per-session selection). + * live route registry; `llm.models` is the session-independent model catalog. * Clients invalidate from the forwarded `llm/adapters-updated` and * `settings/document-updated` owner events. */ import type { RpcRequest, RpcResponse } from './rpc.ts' -import type { ModelCatalogFailure, ModelProviderGroup } from './sessions.ts' +import type { + ModelCatalogFailure, + ModelProviderGroup, +} from '@deepseek-ai/dsh-api-session-controller/types' /** Wire view of one configurable provider. */ export interface ConfigurableProviderView { diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 80dede1799..b24c5d9dfe 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -1,12 +1,9 @@ /** - * RPC method registry and signature-derived generics. The map - * registers only client-request methods (respond is a client-response, so it is absent); - * map keys are the wire path segments (POST /api/session.list). + * RPC method registry and signature-derived generics. Map keys are the wire + * path segments of API Proxy unary calls. */ -import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' -import type { WorkspaceApi } from './workspace.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { GoalsApi } from './goals.ts' @@ -19,23 +16,10 @@ import type { RpcResponse } from './rpc.ts' /** * Method name → method signature. Signatures are the single source of truth; payload/value * types are always derived from here. A method may declare a trailing AbortSignal after the - * request (command.execute): the carrier passes its request signal, never a wire field. + * request; the carrier passes its request signal, never a wire field. */ export interface RpcMethodMap { - 'session.list': SessionsApi['list'] - 'session.search': SessionsApi['search'] - 'session.create': SessionsApi['create'] - 'session.history': SessionsApi['history'] - 'session.models': SessionsApi['models'] - 'session.selectModel': SessionsApi['selectModel'] - 'session.rename': SessionsApi['rename'] - 'session.fork': SessionsApi['fork'] - 'session.prompt': SessionsApi['prompt'] - 'session.attachment': SessionsApi['attachment'] - 'session.updateQueue': SessionsApi['updateQueue'] - 'session.cancel': SessionsApi['cancel'] 'subagent.list': SubagentsApi['list'] - 'subagent.history': SubagentsApi['history'] 'subagent.prompt': SubagentsApi['prompt'] 'subagent.interrupt': SubagentsApi['interrupt'] 'host.describe': HostApi['describe'] @@ -43,13 +27,6 @@ export interface RpcMethodMap { 'host.listDirectory': HostApi['listDirectory'] 'host.createDirectory': HostApi['createDirectory'] 'host.openPath': HostApi['openPath'] - 'workspace.list': WorkspaceApi['list'] - 'workspace.create': WorkspaceApi['create'] - 'workspace.rename': WorkspaceApi['rename'] - 'workspace.delete': WorkspaceApi['delete'] - 'workspace.insertBefore': WorkspaceApi['insertBefore'] - 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] - 'workspace.archiveSession': WorkspaceApi['archiveSession'] 'skill.list': SkillsApi['list'] 'agentPreset.list': AgentPresetsApi['list'] 'agentPreset.select': AgentPresetsApi['select'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 03cfdd1e15..9dacda7237 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -1,14 +1,14 @@ /** - * Message-layer zod schemas: the four wire full forms + error body + - * carrier receipt. The payload slot is unknown in the full-form schemas — business payloads - * get a second parse dispatched by method (two-level parse discipline). - * Brand cast point: rpcIdSchema, and only there. + * Message-layer zod schemas for API Proxy unary calls and Host pushes. The + * payload slot is unknown in the full-form schemas — business payloads get a + * second parse dispatched by method (two-level parse discipline). Brand cast + * point: rpcIdSchema, and only there. */ import { z } from 'zod' import type { z as zCore } from 'zod' type ZodIssue = zCore.core.$ZodIssue -import type { ClientRequest, ClientResponse, RpcError, RpcId, RpcReceipt, ServerRequest, ServerResponse } from './rpc.ts' +import type { ClientRequest, RpcError, RpcId, ServerResponse } from './rpc.ts' /** * Wire widening of a contract type: widens every property (deeply) to `original | undefined`. @@ -35,35 +35,20 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom()) }) }), z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }), - z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }), - z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }), z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }), - z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }), - z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }), - z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), - z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), - z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }), - z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }), z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), - z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), - z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), - z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), - z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }), - z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }), z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }), z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({ @@ -89,7 +74,7 @@ export function rpcResultSchema(value: z.ZodType): z.ZodUnion -/** ServerRequest full form (payload stays wide). */ -export const serverRequestSchema = z.object({ - type: z.literal('server-request'), - rpcId: rpcIdSchema, - method: z.string(), - payload: z.unknown(), -}) as unknown as z.ZodType - -/** ClientResponse full form (result.value stays wide). */ -export const clientResponseSchema = z.object({ - type: z.literal('client-response'), - rpcId: rpcIdSchema, - result: rpcResultSchema(z.unknown().optional()), -}) as unknown as z.ZodType - /** Wire full-form union (discriminated by type). */ export const rpcMessageSchema = z.discriminatedUnion('type', [ clientRequestSchema as unknown as z.ZodObject, serverResponseSchema as unknown as z.ZodObject, - serverRequestSchema as unknown as z.ZodObject, - clientResponseSchema as unknown as z.ZodObject, ]) - -/** Carrier receipt schema. */ -export const rpcReceiptSchema = z.union([ - z.object({ accepted: z.literal(true) }), - z.object({ accepted: z.literal(false), reason: z.union([z.literal('not-pending'), z.literal('bad-response')]) }), -]) satisfies z.ZodType> diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 0b5506b6b6..fbbdbe63d0 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -1,14 +1,12 @@ /** - * Four-quadrant RPC message model. Channels and messages are decoupled: HTTP, - * WebSocket, and in-process SSE are physical carriers, while logical messages - * are channel-independent and form a four-member discriminated union. + * API Proxy request and response message model. Logical messages remain + * independent of their physical carrier. * api/ contract layer: zero Node dependencies, importable from the browser. */ import type { z as zCore } from 'zod' type ZodIssue = zCore.core.$ZodIssue import type { Branded } from '@deepseek-ai/dsh-brand' -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' /** @@ -18,9 +16,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' export type RpcId = Branded<'rpc-id'> /** - * Brands a string as RpcId (same precedent as core `SessionId()`). Minted by the initiator: - * client-request → client mints; server-request → host mints (answerable frames get a stable - * logical id, pure pushes mint a fresh one each time). + * Brands a string as RpcId (same precedent as core `SessionId()`). The Client + * mints each request id and the Host echoes it in the response. * @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures). * @returns The same string, branded (compile-time cast, zero runtime cost). */ @@ -33,31 +30,16 @@ export interface RpcErrorDetailsMap { 'bad-request': { issues: ZodIssue[] } 'cancelled': {} 'session-not-found': { sessionId: SessionId } - 'model-unavailable': { provider: string; model: string } - 'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string } 'invalid-time-zone': { value: string } - 'workspace-attach-failed': { sessionId: SessionId; workspaceId: string } - 'workspace-not-found': { workspaceId: string } - 'workspace-invalid-path': { path: string } - 'workspace-name-conflict': { name: string } - 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } 'directory-unreadable': { path: string } 'directory-exists': { path: string } 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } 'agent-preset-read-only': { agentPreset: string; reason: string } 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } - 'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string } - 'agent-preset-not-found': { agentPreset: string; available: string[] } + 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } - 'attachment-error': { reason: string } - 'queue-item-not-found': { itemId: MessageId } - 'steer-unavailable': { itemId: MessageId } - /** A known slash command reported a usage/state error; the message is the command's own text. */ - 'command-error': {} - /** A leading-/ prompt named no registered command; the message names the token. */ - 'unknown-command': {} /** * A settings write was refused (schema validation, unknown namespace, * read-only provider, or storage failure); the message is the seam's text. @@ -80,8 +62,6 @@ export interface RpcErrorDetailsMap { * details name the endpoint asked, never the credential offered. */ 'model-discovery-failed': { settingsNs: string; baseURL?: string } - 'title-invalid': { sessionId: SessionId } - 'fork-unavailable': { sessionId: SessionId } 'subagent-parent-unavailable': { parentSessionId: SessionId } 'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId } 'subagent-catalog-diagnostic': { @@ -139,7 +119,7 @@ export interface RpcResponse { result: RpcResult } -// ---- Wire full forms: four named members of a discriminated union (discriminant = the four `type` literals) ---- +// ---- Wire full forms ---- /** Call initiated by the client (wire carrier: POST /api/ body). */ export interface ClientRequest { @@ -156,32 +136,5 @@ export interface ServerResponse { result: RpcResult } -/** - * Message initiated by the server (wire carrier: downstream stream frame). Answerable interactions - * (approval/question requested — stable rpcId, reused on replay) and pure pushes - * (session/event etc. — rpcId identifies that one push) share this shape; whether a - * response is expected is determined statically by method (a strict dichotomy, no third kind). - */ -export interface ServerRequest { - type: 'server-request' - rpcId: RpcId - method: string - payload: unknown -} - -/** Response to a ServerRequest (wire carrier: POST /api/respond body); rpcId echoed, never minted anew. */ -export interface ClientResponse { - type: 'client-response' - rpcId: RpcId - result: RpcResult -} - /** Authoritative wire full-form union; narrow via `switch (message.type)`. */ -export type RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse - -/** - * Carrier receipt (not an RpcMessage — it belongs to the carrier layer, same - * discipline as "HTTP status describes only the carrier"): the HTTP response - * body of the POST carrying a client-response. Late/duplicate responses yield not-pending. - */ -export type RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' } +export type RpcMessage = ClientRequest | ServerResponse diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index 747bf19bad..a0a54b6e9f 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -6,7 +6,7 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './sessions.schema.ts' +import { sessionIdSchema } from './ids.schema.ts' import type { SkillEntry } from './skills.ts' /** SkillEntry row of skill.list. */ diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 3b3e711a93..61744f05c9 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -22,10 +22,10 @@ export interface SkillEntry { /** * Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing - * is the domain's only RPC: invocation itself is a plain `session.prompt` - * whose leading `/name` token the host recognizes at the pre-step boundary - * (`dsh-tool-skill` injects the rendered body there), so every client shares - * one deterministic path with no dedicated invocation wire. + * is the domain's only RPC: invocation uses Session Controller's ordinary + * prompt Remote. The host recognizes its leading `/name` token at the pre-step + * boundary (`dsh-tool-skill` injects the rendered body there), so every client + * shares one deterministic path with no dedicated invocation method. */ export interface SkillsApi { /** Lists the user-invocable skill catalog for the session's project. */ diff --git a/packages/host/apiproxy/src/api/subagents.schema.ts b/packages/host/apiproxy/src/api/subagents.schema.ts index 54cbcb2d9b..0943c6c79f 100644 --- a/packages/host/apiproxy/src/api/subagents.schema.ts +++ b/packages/host/apiproxy/src/api/subagents.schema.ts @@ -4,11 +4,11 @@ import { z } from 'zod' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import { - contentBlockSchema, historyEntrySchema, sessionIdSchema, sessionProjectionsBlockSchema, -} from './sessions.schema.ts' +import { sessionIdSchema } from './ids.schema.ts' import type { SubagentListEntry } from './subagents.ts' +const contentBlockSchema = z.looseObject({ type: z.string() }) + /** Healthy and diagnostic durable catalog rows. */ export const subagentListEntrySchema = z.union([ z.object({ @@ -45,22 +45,6 @@ export const subagentListValueSchema = z.object({ parentAvailable: z.boolean(), }) satisfies z.ZodType>> -/** subagent.history request payload. */ -export const subagentHistoryRequestSchema = z.object({ - parentSessionId: sessionIdSchema, - childSessionId: sessionIdSchema, - mode: z.union([z.literal('one-shot'), z.literal('continuable')]), - beforeSeq: z.number().int().nonnegative().optional(), - maxMessages: z.number().int().positive().optional(), -}) satisfies z.ZodType>> - -/** subagent.history response value. */ -export const subagentHistoryValueSchema = z.object({ - events: z.array(historyEntrySchema), - hasMore: z.boolean(), - projections: sessionProjectionsBlockSchema.optional(), -}) as unknown as z.ZodType>> - /** subagent.prompt request payload. */ export const subagentPromptRequestSchema = z.object({ parentSessionId: sessionIdSchema, diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts index 751d48215c..2ca814066b 100644 --- a/packages/host/apiproxy/src/api/subagents.ts +++ b/packages/host/apiproxy/src/api/subagents.ts @@ -1,14 +1,9 @@ -/** - * Browser-safe subagent domain contract. Persisted transcript reads never - * activate an Agent, while continuable prompts route through the exact live - * direct parent into the child's Agent inbox. - */ +/** Browser-safe subagent catalog, continuation, and interrupt contract. */ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RpcRequest, RpcResponse } from './rpc.ts' -import type { HistoryEntry, SessionProjectionsBlock } from './sessions.ts' /** Complete durable direct-child catalog row. */ export type SubagentListEntry = @@ -74,20 +69,6 @@ export interface SubagentsApi { signal?: AbortSignal, ): Promise> - /** - * Reads one healthy catalog child's transcript — the in-memory snapshot of - * a live child, the persisted log of a cold one — with ordinary - * message-aligned pagination and render intents, without Agent activation. - */ - history( - request: RpcRequest, - signal?: AbortSignal, - ): Promise> - /** * Delivers human content to a continuable child through the exact live * parent's continuation owner. Success identifies the message accepted by diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 002e2e9b09..8cb839d20a 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -1,46 +1,21 @@ /** - * Client side of the fetch carrier. AbstractApiClient holds every protocol invariant: rpcId minting, - * four-quadrant envelope wrap/unwrap, zod parsing, in-process SSE frame decoding, and the payload-direct + * Client side of the fetch carrier. AbstractApiClient holds request correlation, + * envelope wrap/unwrap, zod parsing, and the payload-direct * IApiClient domain methods (business code never mints). Platform differences ride two aspects: * abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched. */ import type { z } from 'zod' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { ApiProxy, HostFrame, MuxFrame } from '../api/index.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' -import type { ClientRequest, ClientResponse, RpcMessage, RpcReceipt, RpcRequest, RpcResponse, ServerRequest } from '../api/rpc.ts' +import type { ClientRequest, RpcMessage, RpcResponse } from '../api/rpc.ts' import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' -import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts' -import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts' +import { serverResponseSchema } from '../api/rpc.schema.ts' import { hostCreateDirectoryValueSchema, hostDescribeValueSchema, hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema, } from '../api/host.schema.ts' -import { - sessionCancelValueSchema, - sessionAttachmentValueSchema, - sessionCreateValueSchema, - sessionForkValueSchema, - sessionHistoryValueSchema, - sessionListValueSchema, - sessionModelsValueSchema, - sessionPromptValueSchema, - sessionRenameValueSchema, - sessionSearchValueSchema, - sessionSelectModelValueSchema, - sessionUpdateQueueValueSchema, -} from '../api/sessions.schema.ts' -import { - workspaceArchiveSessionValueSchema, - workspaceCreateValueSchema, - workspaceDeleteValueSchema, - workspaceInsertBeforeValueSchema, - workspaceInsertSessionBeforeValueSchema, - workspaceListValueSchema, - workspaceRenameValueSchema, -} from '../api/workspace.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' import { agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, @@ -63,7 +38,6 @@ import { } from '../api/credentials.schema.ts' import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' import { - subagentHistoryValueSchema, subagentInterruptValueSchema, subagentListValueSchema, subagentPromptValueSchema, @@ -73,36 +47,17 @@ import { * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary * methods take the business payload directly — the carrier mints the rpcId and wraps the * envelope. Business code needing the call's rpcId reads it from the RpcResponse echo. - * Unary methods and respond accept an optional external AbortSignal as the last parameter. + * Unary methods accept an optional external AbortSignal as the last parameter. * Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls * carry only that external signal. In both cases the signal rides beside the request, never * on the wire, like the stream signatures. - * Stream methods accept an optional onOpen callback: it fires once the physical transport is - * readable (before any frame) — the "stream established" signal - * connection controllers need for the readiness handshake. Generators are lazy, so the - * underlying fetch (and therefore onOpen) only happens once iteration starts. * Relationship: ApiProxy is the narrow-form signature contract the impl side implements; * IApiClient is the payload-direct view clients consume; AbstractApiClient bridges the two. * Derived per method key from RpcMethodMap so a map row addition updates this mechanically. */ export interface IApiClient { - sessions: { - list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise>> - search(payload: RequestPayload<'session.search'>, signal?: AbortSignal): Promise>> - create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise>> - history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> - models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise>> - selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise>> - rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise>> - fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise>> - prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> - attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise>> - updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise>> - cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> - } subagents: { list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise>> - history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise>> interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise>> } @@ -113,15 +68,6 @@ export interface IApiClient { createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise>> openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise>> } - workspace: { - list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> - create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> - rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> - delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> - insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise>> - insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> - archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> - } skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } @@ -133,10 +79,6 @@ export interface IApiClient { openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise>> remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise>> } - events: { - mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> - host(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> - } goals: { create(payload: RequestPayload<'goal.create'>, signal?: AbortSignal): Promise>> edit(payload: RequestPayload<'goal.edit'>, signal?: AbortSignal): Promise>> @@ -162,8 +104,6 @@ export interface IApiClient { models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise>> discoverModels(payload: RequestPayload<'llm.discoverModels'>, signal?: AbortSignal): Promise>> } - /** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */ - respond(message: ClientResponse, signal?: AbortSignal): Promise } /** @@ -171,20 +111,7 @@ export interface IApiClient { * mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap). */ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { - 'session.list': sessionListValueSchema, - 'session.search': sessionSearchValueSchema, - 'session.create': sessionCreateValueSchema, - 'session.history': sessionHistoryValueSchema, - 'session.models': sessionModelsValueSchema, - 'session.selectModel': sessionSelectModelValueSchema, - 'session.rename': sessionRenameValueSchema, - 'session.fork': sessionForkValueSchema, - 'session.prompt': sessionPromptValueSchema, - 'session.attachment': sessionAttachmentValueSchema, - 'session.updateQueue': sessionUpdateQueueValueSchema, - 'session.cancel': sessionCancelValueSchema, 'subagent.list': subagentListValueSchema, - 'subagent.history': subagentHistoryValueSchema, 'subagent.prompt': subagentPromptValueSchema, 'subagent.interrupt': subagentInterruptValueSchema, 'host.describe': hostDescribeValueSchema, @@ -192,13 +119,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType void>() - /** @param timeoutMs - timeout for bounded unary calls; user-paced calls and streams do not use it. */ + /** @param timeoutMs - timeout for bounded unary calls; user-paced calls do not use it. */ constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */ @@ -303,12 +223,12 @@ export abstract class AbstractApiClient implements IApiClient { } /** - * Shared POST leg of both C→S carriers (callUnary/respond): JSON body, + * Shared POST leg of unary calls: JSON body, * optional default timeout merged with the caller's external signal, non-2xx → transport throw. */ private async postJson( path: string, - body: ClientRequest | ClientResponse, + body: ClientRequest, signal: AbortSignal | undefined, timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise { @@ -351,84 +271,10 @@ export abstract class AbstractApiClient implements IApiClient { return { rpcId: full.rpcId, result: { ok: true, value } } } - /** Mux stream opener; virtual for the same override reason as callUnary. */ - protected openMux(_payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> { - return this.readSse('/api/events.mux', signal, muxFrameSchema, onOpen) - } - - /** Host stream opener; virtual. */ - protected openHost(_payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> { - return this.readSse('/api/events.host', signal, hostFrameSchema, onOpen) - } - - /** - * SSE protocol path: streaming fetch (not EventSource), '\n\n' framing, ServerRequest envelope + - * frame-schema parse, tap, narrow yield. onOpen fires once the response headers are in and the - * body is readable — the stream-established signal, before any frame arrives. A frame that fails - * either parse level is reported and skipped (one corrupt frame must not kill the stream; the - * client's gap detection covers whatever the frame carried). - */ - protected async *readSse( - path: string, - signal: AbortSignal, - frameSchema: z.ZodType, - onOpen?: () => void, - ): AsyncGenerator> { - const response = await this.doFetch(new URL(path, this.resolveBase()), { signal }) - if (!response.ok || response.body === null) throw new Error(`transport failure for ${path}: HTTP ${response.status}`) - onOpen?.() - const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - try { - while (true) { - const { done, value } = await reader.read() - if (done) return - buffer += decoder.decode(value, { stream: true }) - let boundary: number - while ((boundary = buffer.indexOf('\n\n')) !== -1) { - const chunk = buffer.slice(0, boundary) - buffer = buffer.slice(boundary + 2) - const data = chunk.split('\n').filter(line => line.startsWith('data: ')).map(line => line.slice(6)).join('') - if (data === '') continue - let full: ServerRequest - let frame: F - try { - full = serverRequestSchema.parse(JSON.parse(data)) - frame = frameSchema.parse(full.payload) - } catch (error) { - console.error(`[apiproxy] dropping malformed SSE frame on ${path}:`, error) - continue - } - this.onEnvelope(full) - yield { rpcId: full.rpcId, payload: frame } - } - } - } finally { - await reader.cancel().catch(() => undefined) - } - } - // ---- IApiClient API (arrow properties so destructured/passed references stay bound) ---- - readonly sessions: IApiClient['sessions'] = { - list: (payload, signal) => this.callUnary('session.list', payload, signal), - search: (payload, signal) => this.callUnary('session.search', payload, signal), - create: (payload, signal) => this.callUnary('session.create', payload, signal), - history: (payload, signal) => this.callUnary('session.history', payload, signal), - models: (payload, signal) => this.callUnary('session.models', payload, signal), - selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal), - rename: (payload, signal) => this.callUnary('session.rename', payload, signal), - fork: (payload, signal) => this.callUnary('session.fork', payload, signal), - prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), - attachment: (payload, signal) => this.callUnary('session.attachment', payload, signal), - updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal), - cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), - } - readonly subagents: IApiClient['subagents'] = { list: (payload, signal) => this.callUnary('subagent.list', payload, signal), - history: (payload, signal) => this.callUnary('subagent.history', payload, signal), prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal), interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal), } @@ -445,16 +291,6 @@ export abstract class AbstractApiClient implements IApiClient { openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal), } - readonly workspace: IApiClient['workspace'] = { - list: (payload, signal) => this.callUnary('workspace.list', payload, signal), - create: (payload, signal) => this.callUnary('workspace.create', payload, signal), - rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), - delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), - insertBefore: (payload, signal) => this.callUnary('workspace.insertBefore', payload, signal), - insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), - archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), - } - readonly skills: IApiClient['skills'] = { list: (payload, signal) => this.callUnary('skill.list', payload, signal), } @@ -502,16 +338,6 @@ export abstract class AbstractApiClient implements IApiClient { discoverModels: (payload, signal) => this.callUnary('llm.discoverModels', payload, signal), } - readonly events: IApiClient['events'] = { - mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen), - host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen), - } - - async respond(message: ClientResponse, signal?: AbortSignal): Promise { - this.onEnvelope(message) - const response = await this.postJson('/api/respond', message, signal) - return rpcReceiptSchema.parse(await response.json()) - } } /** diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 697171ec53..7e3a86cfe6 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -6,43 +6,19 @@ * business errors are always 200 + ServerResponse. */ -import { randomUUID } from 'node:crypto' import type { z } from 'zod' -import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts' +import type { ApiProxy } from '../api/index.ts' import { sessionLogQuerySchema } from '../api/downloads.schema.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' -import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts' +import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerResponse } from '../api/rpc.ts' import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' -import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts' -import { - sessionCancelRequestSchema, - sessionAttachmentRequestSchema, - sessionCreateRequestSchema, - sessionForkRequestSchema, - sessionHistoryRequestSchema, - sessionListRequestSchema, - sessionModelsRequestSchema, - sessionPromptRequestSchema, - sessionRenameRequestSchema, - sessionSearchRequestSchema, - sessionSelectModelRequestSchema, - sessionUpdateQueueRequestSchema, -} from '../api/sessions.schema.ts' +import { clientRequestSchema } from '../api/rpc.schema.ts' import { hostCreateDirectoryRequestSchema, hostDescribeRequestSchema, hostListDirectoryRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema, } from '../api/host.schema.ts' -import { - workspaceArchiveSessionRequestSchema, - workspaceCreateRequestSchema, - workspaceDeleteRequestSchema, - workspaceInsertBeforeRequestSchema, - workspaceInsertSessionBeforeRequestSchema, - workspaceListRequestSchema, - workspaceRenameRequestSchema, -} from '../api/workspace.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' import { agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, @@ -65,7 +41,6 @@ import { } from '../api/credentials.schema.ts' import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' import { - subagentHistoryRequestSchema, subagentInterruptRequestSchema, subagentListRequestSchema, subagentPromptRequestSchema, @@ -88,20 +63,7 @@ type UnaryRoutes = { } const UNARY_ROUTES: UnaryRoutes = { - 'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) }, - 'session.search': { schema: sessionSearchRequestSchema, invoke: (api, r, signal) => api.sessions.search(r, signal) }, - 'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) }, - 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) }, - 'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) }, - 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) }, - 'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) }, - 'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) }, - 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, - 'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) }, - 'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) }, - 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) }, - 'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) }, 'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) }, 'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, @@ -109,13 +71,6 @@ const UNARY_ROUTES: UnaryRoutes = { 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, - 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, - 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, - 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, - 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, - 'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(r) }, - 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, - 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, @@ -191,50 +146,6 @@ async function handleUnary( } } -/** SSE frame: complete the narrow RpcRequest into a ServerRequest full form (method = frame type). */ -function fullFrame(narrow: RpcRequest): ServerRequest { - return { type: 'server-request', rpcId: narrow.rpcId, method: narrow.payload.type, payload: narrow.payload } -} - -/** - * Wrap a frame stream as an SSE Response; stops when req.signal aborts. An - * impl throw mid-stream emits one stream/error frame and then closes. - */ -function sseResponse(frames: AsyncIterable>): Response { - const encoder = new TextEncoder() - const stream = new ReadableStream({ - async start(controller) { - try { - // Send an SSE comment line on open so clients/proxies see a live channel (the host - // stream has no baseline frames and would otherwise emit zero bytes while idle; - // a comment line is not a frame, so client frame parsing skips it naturally). - controller.enqueue(encoder.encode(': connected\n\n')) - for await (const narrow of frames) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame(narrow))}\n\n`)) - } - } catch (error: unknown) { - // Mid-stream impl failure → one stream/error frame, then close: the client must see - // the failure instead of a silent end (which reads as a normal disconnect). A fresh - // rpcId is minted — this is a server-initiated push like any other frame. - const failure: MuxFrame | HostFrame = { type: 'stream/error', error: { code: 'internal', message: String(error), details: {} } } - try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame({ rpcId: RpcId(randomUUID()), payload: failure }))}\n\n`)) - } catch { - // Consumer already cancelled the stream: enqueue-after-cancel is the - // only reachable error, and there is no one left to tell. - } - } finally { - try { - controller.close() - } catch { /* already cancelled by the consumer: a double close is the only reachable error */ } - } - }, - }) - return new Response(stream, { - headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }, - }) -} - /** * Wraps an ApiProxy into a pure fetch function (isomorphic point: feed the returned fetch straight to InProcessApiClient). * @param api - the host-side ApiProxy implementation. @@ -249,14 +160,8 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { const url = new URL(req.url) const path = url.pathname - // No-envelope read channels (SSE GET streams + host-only download): + // No-envelope Host-only download channel: // physical routes that answer directly, without a wire envelope. - if (path === '/api/events.mux' && req.method === 'GET') { - return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) - } - if (path === '/api/events.host' && req.method === 'GET') { - return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) - } if (path === '/api/session.export' && (req.method === 'GET' || req.method === 'HEAD')) { // Query params are a different boundary from the POST envelope, but // the request still casts its brands only through the domain schema. @@ -277,7 +182,7 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { // Cross-site write fence: browsers send "simple" POSTs (text/plain, // form encodings) without a CORS preflight, so a malicious page could // otherwise execute side-effectful RPCs blind — the response stays - // unreadable cross-origin, but session.prompt would still run. Only the + // unreadable cross-origin, but the requested mutation would still run. Only the // JSON media type is accepted; anything else is forced into a preflight // this server never answers. 415 = carrier layer, like the 400 below. const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() @@ -293,12 +198,6 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { return new Response('body is not JSON', { status: 400 }) } - if (path === '/api/respond') { - const parsed = clientResponseSchema.safeParse(body) - if (!parsed.success) return Response.json({ accepted: false, reason: 'bad-response' }) - return Response.json(await api.respond(parsed.data)) - } - const method = methodFor(path.slice('/api/'.length)) if (method === undefined) return new Response('not found', { status: 404 }) diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index ac6c770801..546ad8d336 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -7,16 +7,16 @@ * `ctx.apiProxy`). Transport-agnostic by design: this package registers no * routes — physical carriers wrap `ctx.apiProxy` themselves. * - * The gateway consumes `ctx.agentDefaultModel`, the transport-independent default - * shared with direct entry points. Switching models persists through that - * service; sessions that have already logged a selection remain unchanged. + * The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata + * returned by `host.describe`; Session Controller owns Session model selection. */ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent-default-model' +import type {} from '@deepseek-ai/dsh-api-session-controller' import type { ApiProxy } from './api/index.ts' -import { createApiProxy, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './api-proxy.ts' +import { createApiProxy } from './api-proxy.ts' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, type SessionLogCompressionLevel, @@ -53,35 +53,26 @@ export interface Config { * @default 6 */ sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 - /** - * Maximum physical size of a cold Session artifact eligible for blankness - * verification. Zero disables probes. - * @default 1024 - */ - coldBlankProbeMaxBytes?: number } /** * The API gateway service: implements the ApiProxy contract over the composed - * host context and provides it as `ctx.apiProxy`. The Host cwd is the default - * project directory. + * host context and provides it as `ctx.apiProxy`. Its cwd metadata must match + * the default project directory supplied to Session Controller. */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery', - 'tools', 'userQuestions', 'workspaceRegistry', + 'sessionController', ] static Config: z = z.object({ nativeOpen: z.boolean(), sessionExportCompressionLevel: z.number().step(1).min(0).max(9) .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z, - coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), }) - readonly sessions: ApiProxy['sessions'] readonly subagents: ApiProxy['subagents'] - readonly workspace: ApiProxy['workspace'] readonly host: ApiProxy['host'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] @@ -89,27 +80,19 @@ export class ApiProxyService extends Service implements ApiProxy { readonly settings: ApiProxy['settings'] readonly credentials: ApiProxy['credentials'] readonly llm: ApiProxy['llm'] - readonly events: ApiProxy['events'] readonly downloads: ApiProxy['downloads'] - readonly respond: ApiProxy['respond'] constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') const api = createApiProxy(ctx, { defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), - saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection), cwd: process.cwd(), ...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean }, ...(config.sessionExportCompressionLevel === undefined ? {} : { sessionExportCompressionLevel: config.sessionExportCompressionLevel }), - ...(config.coldBlankProbeMaxBytes === undefined - ? {} - : { coldBlankProbeMaxBytes: config.coldBlankProbeMaxBytes }), }) - this.sessions = api.sessions this.subagents = api.subagents - this.workspace = api.workspace this.host = api.host this.goals = api.goals this.skills = api.skills @@ -117,11 +100,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.settings = api.settings this.credentials = api.credentials this.llm = api.llm - this.events = api.events this.downloads = api.downloads - // createApiProxy returns closures (no `this` capture), so the bind is - // behavior-neutral. - this.respond = api.respond.bind(api) } } diff --git a/packages/host/apiproxy/src/invariant.ts b/packages/host/apiproxy/src/invariant.ts index ac21250e90..9e9489aaff 100644 --- a/packages/host/apiproxy/src/invariant.ts +++ b/packages/host/apiproxy/src/invariant.ts @@ -16,10 +16,8 @@ export const inject = ['invariants'] /** * No runtime invariant: this package is the wire contract layer plus the - * host-side gateway over services owned elsewhere — it emits no cordis events - * of its own; the session/agent event streams it projects are asserted by - * their owning packages' companions. rpcId round-trip and schema acceptance - * are enforced at the carrier boundary and exercised by the + * host-side unary gateway over services owned elsewhere. rpcId round-trip and + * schema acceptance are enforced at the carrier boundary and exercised by the * protocol-isomorphism suite. */ const install: InvariantInstaller = () => {} diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 072fd2ab2e..13000567d3 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -1,9 +1,4 @@ -/** - * A session's agent preset is fixed at creation. The gateway records the - * resolved id on the header and refuses to adopt the identity under a different - * one, because the session's history was produced under that preset's tools: - * rebuilding it differently would replay tool calls the new agent cannot make. - */ +/** API Proxy behavior for Agent preset management and preset-scoped catalogs. */ import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -12,9 +7,8 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' import { RpcId, type RpcRequest } from '../src/api/rpc.ts' -import type { HostFrame } from '../src/api/events.ts' +import type { ApiProxy } from '../src/api/index.ts' import { InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' @@ -28,6 +22,30 @@ function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload } } +const sessionHarnesses = new WeakMap() + +async function createSession( + api: ApiProxy, + request: { readonly sessionId: SessionId; readonly agentPreset?: string }, +): Promise { + const harness = sessionHarnesses.get(api) + if (harness === undefined) throw new Error('Session test harness is not installed') + const presets = harness.ctx.get('agentPresets') + const agentPreset = presets === undefined + ? undefined + : (await presets.resolve(request.agentPreset)).id + await harness.ctx.agents.create({ + sessionId: request.sessionId, + meta: { + cwd: harness.cwd, + ...(agentPreset === undefined ? {} : { agentPreset }), + }, + ...(agentPreset === undefined || presets === undefined + ? {} + : { setup: async (agentCtx: Context) => { await presets.mount(agentCtx, agentPreset) } }), + }) +} + /** Minimal live agent; the gateway only needs identity and its session. */ function stubAgent(session: Session): Agent { return { id: session.id, session, status: 'idle' } as unknown as Agent @@ -78,10 +96,7 @@ function roster(ids: readonly string[], userIds: readonly string[] = []): unknow // The standing scope key a cold transcript read resolves presenters in. standingKeyFor: (id?: string) => { const wanted = id ?? ids[0] ?? '' - standingKeyRequests.push(wanted) - if (!ids.includes(wanted) || failingStandingKeys.has(wanted)) { - return Promise.reject(new UnknownPresetError(wanted, ids)) - } + if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) let key = standingKeys.get(wanted) if (key === undefined) { key = { agentPreset: wanted } @@ -92,26 +107,20 @@ function roster(ids: readonly string[], userIds: readonly string[] = []): unknow } } -/** Standing keys the roster double minted, and the ids readers asked for. */ +/** Standing keys minted by the roster double. */ const standingKeys = new Map() -const standingKeyRequests: string[] = [] -/** Preset ids whose standing mount the double reports as unusable. */ -const failingStandingKeys = new Set() /** Per-agent service instances a mounted preset would own, keyed by session id. */ const services = new Map>() async function harness( presets?: readonly string[], - persistence?: unknown, options: { userIds?: readonly string[]; defaults?: Record } = {}, ) { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-'))) const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) - ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never) if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never) const factory: AgentFactory = { @@ -135,122 +144,35 @@ async function harness( }, } ctx.agents.setFactory(factory) - const api = createApiProxy(ctx, { + ctx.provide('sessionController', { + resolveAgent: (sessionId: SessionId) => { + const agent = ctx.agents.get(sessionId) + return Promise.resolve(agent === undefined + ? { + error: { + code: 'session-not-found', + message: `session "${sessionId}" not found`, + details: { sessionId }, + }, + } + : { agent }) + }, + inspect: (sessionId: SessionId) => { + const session = ctx.sessions.get(sessionId) + if (session === undefined) throw new Error(`session "${sessionId}" not found`) + return Promise.resolve({ meta: session.header, events: [...session.events] }) + }, + } as never) + const defaults = { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, ...options.defaults, - }) + } + const api = createApiProxy(ctx, defaults) + sessionHarnesses.set(api, { ctx, cwd }) return { api, ctx, cwd } } -describe('session.create with an agent preset', () => { - it('records the resolved preset on the session header', async () => { - const { api, ctx } = await harness(['standard', 'minimal']) - - const created = await api.sessions.create(request({ sessionId: SessionId('s1'), agentPreset: 'minimal' })) - - expect(created.result.ok).toBe(true) - expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal') - }) - - it('records the default when the caller names none', async () => { - const { api, ctx } = await harness(['standard', 'minimal']) - - await api.sessions.create(request({ sessionId: SessionId('s2') })) - - expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard') - }) - - it('rejects an unknown preset and names the ones that exist', async () => { - const { api } = await harness(['standard']) - - const response = await api.sessions.create(request({ sessionId: SessionId('s3'), agentPreset: 'nope' })) - - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error.code).toBe('agent-preset-not-found') - }) - - it('refuses to adopt a live session under a different preset', async () => { - const { api } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'minimal' })) - - const response = await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'standard' })) - - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error.code).toBe('agent-preset-conflict') - expect(response.result.error.details).toEqual({ - sessionId: 's4', - requestedPreset: 'standard', - existingPreset: 'minimal', - }) - }) - - it('adopts a live session under the preset it SWITCHED to', async () => { - const { api, ctx } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' })) - // Exactly what `agentPreset.select` leaves behind on a blank session: the - // header keeps the creation fact, the log states what the agent runs. - ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) - - const adopted = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'minimal' })) - const stale = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' })) - - // Comparing against the header would invert both answers: the preset the - // session actually runs would be refused, and the one it left would pass. - expect(adopted.result.ok).toBe(true) - // The echo has to name the same preset the adoption just accepted, or the - // client labels the session with one it has already left — and disagrees - // with the row `session.list` serves for it. - if (!adopted.result.ok) throw new Error('unreachable') - expect(adopted.result.value).toMatchObject({ agentPreset: 'minimal' }) - expect(stale.result.ok).toBe(false) - if (stale.result.ok) throw new Error('unreachable') - expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' }) - }) - - it('adopts a live session unchanged when the caller names no preset', async () => { - const { api } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' })) - - // Reconnecting and retrying a create must stay ordinary operations. - const response = await api.sessions.create(request({ sessionId: SessionId('s5') })) - - expect(response.result.ok).toBe(true) - }) - - it('leaves the header preset-less when no roster is composed', async () => { - const { api, ctx } = await harness() - - await api.sessions.create(request({ sessionId: SessionId('s6') })) - - expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() - }) - - it('says why a preset-less session cannot be adopted under one', async () => { - // Two callers reach this: a deployment that composes no roster, and a - // session created before one existed. Both record no preset, so naming - // any is a conflict rather than an adoption — the history was produced - // under a composition this roster cannot name. The message has to say - // that, because "already runs agent preset undefined" reads as a bug. - const { api } = await harness() - await api.sessions.create(request({ sessionId: SessionId('s7') })) - - const response = await api.sessions.create(request({ sessionId: SessionId('s7'), agentPreset: 'standard' })) - - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error.code).toBe('agent-preset-conflict') - expect(response.result.error.message).toContain('records no agent preset') - expect(response.result.error.details).toEqual({ - sessionId: 's7', - requestedPreset: 'standard', - existingPreset: undefined, - }) - }) -}) - /** * A capability a preset mounts is reachable from nowhere the host normally * looks: an `isolate` realm is what makes it per session. The gateway serves @@ -260,7 +182,7 @@ describe('session.create with an agent preset', () => { describe('a capability the session\'s preset mounts', () => { it('serves the goal RPC from the session\'s own goal service', async () => { const { api } = await harness(['standard']) - await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('g1'), agentPreset: 'standard' }) const ref = { id: GoalId('goal-1'), revision: 1 } const paused: unknown[] = [] services.set('g1', { @@ -277,7 +199,7 @@ describe('a capability the session\'s preset mounts', () => { it('serves the skill catalog from the session\'s own registry', async () => { const { api } = await harness(['standard']) - await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('k1'), agentPreset: 'standard' }) services.set('k1', { skills: { list: () => Promise.resolve([{ @@ -298,7 +220,7 @@ describe('a capability the session\'s preset mounts', () => { it('says so when no composition mounts the capability at all', async () => { const { api } = await harness(['standard']) - await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('n1'), agentPreset: 'standard' }) const response = await api.skills.list(request({ sessionId: SessionId('n1') })) @@ -344,7 +266,7 @@ describe('agentPreset.list', () => { describe('agentPreset.select', () => { it('recomposes a blank session', async () => { const { api } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('sel-1'), agentPreset: 'standard' }) const response = await api.agentPresets.select( request({ sessionId: SessionId('sel-1'), agentPreset: 'minimal' })) @@ -354,9 +276,9 @@ describe('agentPreset.select', () => { expect(response.result.value.agentPreset).toBe('minimal') }) - it('records the switch in the log, and the list reads it back', async () => { + it('records the switch in the log', async () => { const { api, ctx } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('sel-log'), agentPreset: 'standard' }) await api.agentPresets.select( request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' })) @@ -368,48 +290,11 @@ describe('agentPreset.select', () => { if (session === undefined) throw new Error('unreachable') expect(session.header.agentPreset).toBe('standard') expect(resolveSessionPreset(session)).toBe('minimal') - const listed = await api.sessions.list(request({})) - if (!listed.result.ok) throw new Error('unreachable') - expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset) - .toBe('minimal') - }) - - it('forwards the owner event so clients can drop that session\'s catalogs', async () => { - const { api, ctx } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' })) - // The host-stream opener reads the committed-workspace baseline; this - // spec owns preset identity, so the stub suffices (api-proxy-commands - // precedent). - ctx.provide('workspaceRegistry', { list: () => [] } as never) - const abort = new AbortController() - const frames: HostFrame[] = [] - const stream = api.events.host(request({}), abort.signal) - const consume = (async () => { - for await (const frame of stream) { - if (frame.payload.type === 'host/remote-event' - && frame.payload.event === 'agent-preset/selected') frames.push(frame.payload) - } - })() - - // AgentPresets owns the committed-log-to-event mapping; this spec owns the - // forwarding of that event without recreating the owner's implementation. - ctx.emit('agent-preset/selected', SessionId('sel-frame'), 'minimal') - // The queue push is synchronous; one turn lets the async iterator consume - // it before the stream closes. - await new Promise(resolve => setTimeout(resolve, 0)) - abort.abort() - await consume - - // Recomposing registers nothing, so the owner event — not the - // registry-wide commands one — tells clients their cached catalogs are stale. - expect(frames).toEqual([ - { type: 'host/remote-event', event: 'agent-preset/selected', args: ['sel-frame', 'minimal'] }, - ]) }) it('serializes two concurrent selects on one session', async () => { const { api, ctx } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('sel-race'), agentPreset: 'standard' }) // Both pass the blank check; unserialized, the second unmount finds no // record because the first already removed it, and two compositions end up @@ -429,7 +314,7 @@ describe('agentPreset.select', () => { it('refuses once the conversation has started', async () => { const { api, ctx } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('sel-2'), agentPreset: 'standard' }) // One turn is enough: the history from here on was produced under // `standard`'s tools, and a swap would strand those tool calls. ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 }) @@ -444,7 +329,7 @@ describe('agentPreset.select', () => { it('reports an unknown preset without disturbing the session', async () => { const { api } = await harness(['standard']) - await api.sessions.create(request({ sessionId: SessionId('sel-3') })) + await createSession(api, { sessionId: SessionId('sel-3') }) const response = await api.agentPresets.select( request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' })) @@ -456,7 +341,7 @@ describe('agentPreset.select', () => { it('reports a deployment that composes no presets', async () => { const { api } = await harness() - await api.sessions.create(request({ sessionId: SessionId('sel-4') })) + await createSession(api, { sessionId: SessionId('sel-4') }) const response = await api.agentPresets.select( request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' })) @@ -547,7 +432,7 @@ describe('authoring over the wire', () => { describe('opening a preset directory', () => { it('hands the resolved directory to the native opener', async () => { const opened: string[] = [] - const { api } = await harness(['standard', 'my-preset'], undefined, { + const { api } = await harness(['standard', 'my-preset'], { userIds: ['my-preset'], defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } }, }) @@ -563,7 +448,7 @@ describe('opening a preset directory', () => { }) it('answers the path as text where the deployment has no opener', async () => { - const { api } = await harness(['standard', 'my-preset'], undefined, { + const { api } = await harness(['standard', 'my-preset'], { userIds: ['my-preset'], defaults: { canOpenPath: () => false }, }) @@ -578,7 +463,7 @@ describe('opening a preset directory', () => { it('refuses a preset that ships with the deployment', async () => { const opened: string[] = [] - const { api } = await harness(['standard'], undefined, { + const { api } = await harness(['standard'], { defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } }, }) @@ -594,10 +479,10 @@ describe('opening a preset directory', () => { }) it('reports the roster capability on list', async () => { - const openable = await harness(['standard'], undefined, { + const openable = await harness(['standard'], { defaults: { canOpenPath: () => true }, }) - const headless = await harness(['standard'], undefined, { + const headless = await harness(['standard'], { defaults: { canOpenPath: () => false }, }) @@ -609,7 +494,7 @@ describe('opening a preset directory', () => { }) it('counts an injected opener as openable', async () => { - const { api } = await harness(['standard'], undefined, { + const { api } = await harness(['standard'], { defaults: { openPath: () => Promise.resolve() }, }) @@ -629,7 +514,7 @@ describe('skills over the layered host registry', () => { return Promise.resolve([]) }, } as never) - await api.sessions.create(request({ sessionId: SessionId('h1'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('h1'), agentPreset: 'standard' }) const response = await api.skills.list(request({ sessionId: SessionId('h1') })) @@ -671,60 +556,3 @@ describe('skills over the layered host registry', () => { expect(seen).toEqual([undefined]) }) }) - -describe('session.history presenter scope', () => { - it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => { - const { api } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'minimal' })) - // Cold: creation registered a live agent in this harness, so simulate the - // cold path by asking for a session only persistence knows... the harness - // has no persistence, so read the live one and assert no roster query. - standingKeyRequests.length = 0 - const live = await api.sessions.history(request({ sessionId: SessionId('p1') })) - expect(live.result.ok).toBe(true) - // A live agent IS the presenter scope; the roster is not consulted. - expect(standingKeyRequests).toEqual([]) - }) - - it('resolves a switched session from the LOG, not its creation header', async () => { - // The header is a creation fact; a switch while blank is a logged event, - // and every turn after it ran under the newer composition. Reading the - // header would render that history through the older preset's layer, - // where the tools it is made of have no presenter at all. - const meta = { id: SessionId('p4'), createdAt: 1, cwd: '/tmp/p4', agentPreset: 'standard' } - const { api } = await harness(['standard', 'minimal'], { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ - meta, - events: [{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }], - }), - }) - - standingKeyRequests.length = 0 - const response = await api.sessions.history(request({ sessionId: SessionId('p4') })) - - expect(response.result.ok).toBe(true) - expect(standingKeyRequests).toEqual(['minimal']) - }) - - it('serves a COLD transcript whose standing mount is no longer usable', async () => { - // A genuinely cold session: persistence knows it, no live agent exists. - const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' } - const { api } = await harness(['standard'], { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events: [] }), - }) - // The preset broke after the session ran: the roster rejects the mount. - failingStandingKeys.add('standard') - try { - standingKeyRequests.length = 0 - const response = await api.sessions.history(request({ sessionId: SessionId('p3') })) - // Degraded, never failed: the roster WAS asked, and the transcript - // still serves — with the generic cards a viewless entry renders. - expect(standingKeyRequests).toEqual(['standard']) - expect(response.result.ok).toBe(true) - } finally { - failingStandingKeys.delete('standard') - } - }) -}) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 3ba6f3a0af..2916222805 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -1,5 +1,5 @@ /** - * Settings/credentials/llm RPC domains and their host-stream frames over + * Settings/credentials/llm RPC domains and their owner events over * createApiProxy: layered redacted describe, write-path rejection mapping, * value-free credential views, the directory/live-route merge, and the three * invalidation frames (settings/credentials/models changed). @@ -12,7 +12,6 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' import LlmRuntime, { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -27,7 +26,6 @@ import type { CredentialRef, ResolvedCredential, } from '@deepseek-ai/dsh-credentials' -import type { HostFrame } from '../src/api/index.ts' import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' @@ -211,7 +209,6 @@ async function harness(options?: { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) - await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) await ctx.plugin(LlmRuntime) if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings) @@ -223,50 +220,55 @@ async function harness(options?: { { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, ]) } - // Host-stream opener reads the committed-workspace baseline; the stub - // suffices — the real workspace composition is api-proxy-workspace.spec's. - ctx.provide('workspaceRegistry', { list: () => [] } as never) return ctx } -/** Drain `count` host frames matching `types`, then abort the stream. */ -async function collectHost( - api: ReturnType, - types: string[], - count: number, +/** Observe settings commits while one API operation runs. */ +async function captureSettingsUpdates( + ctx: Context, run: () => Promise, -): Promise { - const abort = new AbortController() - const frames: HostFrame[] = [] - const stream = api.events.host(request({}), abort.signal) - const consume = (async () => { - for await (const frame of stream) { - if (!types.includes(frame.payload.type)) continue - frames.push(frame.payload) - if (frames.length >= count) abort.abort() - } - })() - await run() - await consume - return frames +): Promise> { + const updates: Array = [] + const dispose = ctx.on('settings/document-updated', (namespace, revision) => { + updates.push([namespace, revision]) + }) + try { + await run() + return updates + } finally { + dispose() + } } -/** - * One forwarded `settings/document-updated` frame for `ns`. The revision rides - * the host's own argument list, so it is matched by shape rather than pinned to - * a per-test count. - * @param ns - the namespace whose stored section changed. - * @returns the expected wrapper frame. - */ -function forwardedSettings(ns: string): HostFrame { - return { - type: 'host/remote-event', - event: 'settings/document-updated', - // The revision is the Host's own counter, so the matcher is the assertion. - args: [ns, expect.any(Number)], // oxlint-disable-line typescript/no-unsafe-assignment +/** Observe credential commits while one API operation runs. */ +async function captureCredentialUpdates(ctx: Context, run: () => Promise): Promise { + const updates: CredentialRef[] = [] + const dispose = ctx.on('credentials/reference-updated', (ref) => { updates.push(ref) }) + try { + await run() + return updates + } finally { + dispose() } } +/** Count model-adapter topology commits while one API operation runs. */ +async function countAdapterUpdates(ctx: Context, run: () => Promise): Promise { + let updates = 0 + const dispose = ctx.on('llm/adapters-updated', () => { updates += 1 }) + try { + await run() + return updates + } finally { + dispose() + } +} + +/** Expected settings event tuple with its owner-assigned revision. */ +function expectedSettingsUpdate(ns: string): readonly unknown[] { + return [ns, expect.any(Number)] +} + describe('settings domain', () => { it('reports an actionable error when no settings provider is mounted', async () => { const ctx = await harness({ settings: false }) @@ -446,7 +448,7 @@ describe('settings domain', () => { const api = createApiProxy(ctx, DEFAULTS) expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) .toEqual(['ui-onboarding', 'ui-theme']) - const frames = await collectHost(api, ['host/remote-event'], 2, async () => { + const updates = await captureSettingsUpdates(ctx, async () => { expectOk(await api.settings.mutate(request({ ns: 'ui-onboarding', ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }], @@ -456,7 +458,10 @@ describe('settings domain', () => { ops: [{ op: 'set', path: ['preference'], value: 'dark' }], }))) }) - expect(frames).toEqual([forwardedSettings('ui-onboarding'), forwardedSettings('ui-theme')]) + expect(updates).toEqual([ + expectedSettingsUpdate('ui-onboarding'), + expectedSettingsUpdate('ui-theme'), + ]) }) it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => { @@ -496,10 +501,10 @@ describe('settings domain', () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/remote-event'], 1, async () => { + const updates = await captureSettingsUpdates(ctx, async () => { await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } })) }) - expect(frames).toEqual([forwardedSettings('llm-deepseek')]) + expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')]) // The resolved value never moved: base already said https://base. expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.value) .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) @@ -512,11 +517,10 @@ describe('settings domain', () => { }), { base: { defaultPreset: 'read-only' }, }) - const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/remote-event'], 1, async () => { + const updates = await captureSettingsUpdates(ctx, async () => { await permission.update({ defaultPreset: 'workspace-write' }) }) - expect(frames).toEqual([forwardedSettings('permission')]) + expect(updates).toEqual([expectedSettingsUpdate('permission')]) }) it('forwards an Agent-default settings change for model-catalog consumers', async () => { @@ -525,14 +529,13 @@ describe('settings domain', () => { provider: z.string().required(), model: z.string().required(), }), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) - const api = createApiProxy(ctx, DEFAULTS) // The shared section names the selection every blank session resolves to, // so an externally edited default — another tab, a // hand-edited settings.yaml — has to reach an open selector as well. - const frames = await collectHost(api, ['host/remote-event'], 1, async () => { + const updates = await captureSettingsUpdates(ctx, async () => { await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) }) - expect(frames).toEqual([forwardedSettings('agent-default-model')]) + expect(updates).toEqual([expectedSettingsUpdate('agent-default-model')]) }) it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => { @@ -553,14 +556,14 @@ describe('settings domain', () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/remote-event'], 1, async () => { + const updates = await captureSettingsUpdates(ctx, async () => { const view = expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { apiKey: 'sk-new', baseURL: 'https://next' } }))) expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://next' }) expect(view.user).toEqual({ baseURL: 'https://next' }) expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }]) expect(JSON.stringify(view)).not.toContain('sk-new') }) - expect(frames).toEqual([forwardedSettings('llm-deepseek')]) + expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')]) }) it('replace resets the user layer wholesale', async () => { @@ -624,17 +627,14 @@ describe('credentials domain', () => { const api = createApiProxy(ctx, DEFAULTS) const before = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] }))) expect(before.credentials).toEqual({ OPENAI_API_KEY: { configured: false, writable: true } }) - const frames = await collectHost(api, ['host/remote-event'], 2, async () => { + const updates = await captureCredentialUpdates(ctx, async () => { expectOk(await api.credentials.set(request({ ref: 'OPENAI_API_KEY', value: 'sk-secret' }))) const after = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] }))) expect(after.credentials).toEqual({ OPENAI_API_KEY: { configured: true, source: 'file', writable: true } }) expect(JSON.stringify(after)).not.toContain('sk-secret') expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' }))) }) - expect(frames).toEqual([ - { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] }, - { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] }, - ]) + expect(updates).toEqual(['OPENAI_API_KEY', 'OPENAI_API_KEY']) }) it('maps a shadowed write onto credential-rejected for set and unset alike', async () => { @@ -692,16 +692,12 @@ describe('llm domain', () => { it('forwards llm/adapters-updated at every topology commit point', async () => { const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/remote-event'], 2, async () => { + const updates = await countAdapterUpdates(ctx, async () => { const dispose = ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [])) dispose() return Promise.resolve() }) - expect(frames).toEqual([ - { type: 'host/remote-event', event: 'llm/adapters-updated', args: [] }, - { type: 'host/remote-event', event: 'llm/adapters-updated', args: [] }, - ]) + expect(updates).toBe(2) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-host.spec.ts b/packages/host/apiproxy/tests/api-proxy-host.spec.ts new file mode 100644 index 0000000000..74c068150f --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-host.spec.ts @@ -0,0 +1,205 @@ +import { homedir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' +import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '../src/api-proxy.ts' + +let nextRpc = 1 +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`host-${String(nextRpc++)}`), payload } +} + +function expectOk(response: { readonly result: { readonly ok: true; readonly value: T } | { readonly ok: false } }): T { + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + return response.result.value +} + +async function harness( + picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, + extras: { + openPath?: (path: string, signal: AbortSignal) => Promise + canOpenPath?: () => boolean + } = {}, +) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(AgentRegistry) + ctx.provide('directoryPicker', { capability: () => picker } as never) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), + cwd: '/tmp/dsh-apiproxy-host', + ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, + ...extras.canOpenPath === undefined ? {} : { canOpenPath: extras.canOpenPath }, + }) + return { api } +} + +describe('host.pickDirectory', () => { + it('returns a selected path or explicit cancellation from the native capability', async () => { + const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' }) + expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) + .toEqual({ ok: true, value: { path: '/tmp/project' } }) + + const cancelled = await harness({ kind: 'native', pick: async () => null }) + expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) + .toEqual({ ok: true, value: { path: null } }) + }) + + it('propagates abort into the native capability as a cancelled RPC error', async () => { + const { api } = await harness({ + kind: 'native', + pick: signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }), + }) + const abort = new AbortController() + const pending = api.host.pickDirectory(request({}), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) + + it('folds a non-abort native-chooser failure into an internal error', async () => { + const { api } = await harness({ + kind: 'native', + pick: async () => { throw new Error('no chooser installed') }, + }) + const response = await api.host.pickDirectory(request({}), new AbortController().signal) + expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } }) + }) + + it('refuses the native RPC under a browse composition', async () => { + const { api } = await harness(BROWSE_STUB) + const response = await api.host.pickDirectory(request({}), new AbortController().signal) + expect(response.result).toMatchObject({ + ok: false, + error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } }, + }) + }) +}) + +const BROWSE_STUB: DirectoryPickerCapability = { + kind: 'browse', + list: async (path) => { + if (path === '/denied') { + throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') + } + const target = path ?? '/home/user' + return { + path: target, + home: '/home/user', + crumbs: [{ name: '/', path: '/', hidden: false }], + entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], + truncated: false, + } + }, + createDirectory: async (path, name) => { + if (name === 'taken') { + throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') + } + if (name === 'unwritable') throw new Error('disk detached') + return `${path}/${name}` + }, +} + +describe('host.listDirectory / host.createDirectory', () => { + it('serves listings and creation through the browse capability, defaulting to home', async () => { + const { api } = await harness(BROWSE_STUB) + const home = await api.host.listDirectory(request({}), new AbortController().signal) + expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) + const listed = await api.host.listDirectory( + request({ path: '/home/user/projects' }), + new AbortController().signal, + ) + expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } }) + const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' })) + expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } }) + }) + + it('maps typed picker failures onto wire errors and folds unknown throws to internal', async () => { + const { api } = await harness(BROWSE_STUB) + expect((await api.host.listDirectory( + request({ path: '/denied' }), + new AbortController().signal, + )).result).toMatchObject({ + ok: false, + error: { code: 'directory-unreadable', details: { path: '/denied' } }, + }) + expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result) + .toMatchObject({ ok: false, error: { code: 'directory-exists' } }) + expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result) + .toMatchObject({ ok: false, error: { code: 'internal' } }) + }) + + it('reports an aborted listing as cancelled', async () => { + const { api } = await harness({ + kind: 'browse', + list: (_path, signal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) + }), + createDirectory: async () => '/never', + }) + const abort = new AbortController() + const pending = api.host.listDirectory(request({}), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) + + it('refuses the browse RPCs under a native composition', async () => { + const { api } = await harness() + expect((await api.host.listDirectory(request({}), new AbortController().signal)).result) + .toMatchObject({ + ok: false, + error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, + }) + expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result) + .toMatchObject({ + ok: false, + error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, + }) + }) +}) + +describe('host.openPath', () => { + it('describes whether the deployment can reach a native desktop', async () => { + const visible = await harness(undefined, { canOpenPath: () => true }) + const headless = await harness(undefined, { canOpenPath: () => false }) + expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true) + expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false) + expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) + }) + + it('opens through the injected native boundary', async () => { + const opened: string[] = [] + const { api } = await harness(undefined, { + openPath: async (path) => { opened.push(path) }, + }) + expect((await api.host.openPath( + request({ path: '/tmp/a.txt' }), + new AbortController().signal, + )).result).toEqual({ ok: true, value: { opened: true } }) + expect(opened).toEqual(['/tmp/a.txt']) + }) + + it('propagates abort into the native boundary as a cancelled RPC error', async () => { + const { api } = await harness(undefined, { + openPath: (_path, signal) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }), + }) + const abort = new AbortController() + const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts new file mode 100644 index 0000000000..e0ff471a57 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts @@ -0,0 +1,77 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { ApiSessionNotFound } from '@deepseek-ai/dsh-api-session-controller' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-skill' +import { describe, expect, it, vi } from 'vitest' +import { createApiProxy } from '../src/api-proxy.ts' +import { RpcId } from '../src/api/rpc.ts' + +describe('skill catalog Session inspection', () => { + it('reads a detached Session without resuming its Agent', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = SessionId('cold-skills') + const resolveAgent = vi.fn() + const inspect = vi.fn(() => Promise.resolve({ + meta: { version: 0 as const, id: sessionId, createdAt: 1, cwd: '/cold/project' }, + events: [], + })) + ctx.provide('sessionController', { inspect, resolveAgent } as never) + const list = vi.fn(() => Promise.resolve([{ + name: 'review', + description: 'Review the current change.', + invocation: { modelInvocable: true, userInvocable: true }, + }])) + ctx.provide('skills', { list } as never) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + }) + + const response = await api.skills.list({ rpcId: RpcId('cold-skills'), payload: { sessionId } }) + + expect(response.result).toEqual({ + ok: true, + value: { + skills: [{ + name: 'review', + description: 'Review the current change.', + modelInvocable: true, + }], + }, + }) + expect(inspect).toHaveBeenCalledWith(sessionId) + expect(resolveAgent).not.toHaveBeenCalled() + expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined }) + }) + + it('preserves missing and failed cold inspection as distinct API errors', async () => { + const sessionId = SessionId('missing-skills') + for (const fixture of [ + { + error: new ApiSessionNotFound('session "missing-skills" not found'), + code: 'session-not-found', + }, + { error: new Error('storage offline'), code: 'internal' }, + ] as const) { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + ctx.provide('sessionController', { + inspect: () => Promise.reject(fixture.error), + resolveAgent: vi.fn(), + } as never) + ctx.provide('skills', { list: vi.fn() } as never) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + }) + + const response = await api.skills.list({ rpcId: RpcId(fixture.code), payload: { sessionId } }) + + expect(response.result).toMatchObject({ ok: false, error: { code: fixture.code } }) + } + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index a11e2a0416..bbdddd878d 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import { SubagentError } from '@deepseek-ai/dsh-subagent' import { RpcId } from '../src/api/rpc.ts' import type { RpcRequest } from '../src/api/rpc.ts' @@ -21,13 +21,6 @@ function bench(options: { followupError?: Error interruptError?: Error listError?: Error - /** Persistence forgets the child entirely (the vanished-mid-read race). */ - storedChild?: false - /** Attach the child to the live session store instead of persistence only. */ - liveChild?: true - /** Every registered projection unit throws on this child's payloads. */ - projectionsThrow?: true - historyParent?: SessionId } = {}) { const parent = { id: PARENT } const child = options.childStatus === undefined @@ -63,49 +56,13 @@ function bench(options: { ) => { if (options.interruptError !== undefined) throw options.interruptError }) - const childHeader = { - version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT, - } satisfies SessionHeader - const childEvents = [ - { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } }, - ] as unknown as SessionEvent[] - const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents })) - const liveBlock = { values: {}, asOfSeq: 3 } - const coldBlock = { values: {}, asOfSeq: 0 } - const snapshot = vi.fn(() => { - if (options.projectionsThrow === true) throw new Error('hostile unit') - return liveBlock - }) - const restore = vi.fn(() => { - if (options.projectionsThrow === true) throw new Error('hostile unit') - return { snapshot: coldBlock } - }) const ctx = new Context() ctx.provide('agents', { get: getAgent }) ctx.provide('subagents', { listChildren, followup, interrupt }) - ctx.provide('sessions', { - get: (id: SessionId) => options.liveChild === true && id === CHILD - ? { id: CHILD, header: childHeader, events: childEvents } - : undefined, - }) - ctx.provide('sessionPersistence', { - list: () => Promise.resolve(options.storedChild === false ? [] : [childHeader]), - inspect, - locate: () => undefined, - }) - // The gateway's own projection push feed subscribes at construction; the - // no-op disposer keeps that feed quiet while these tests pin history reads. - ctx.provide('sessionProjections', { - snapshot, - restore, - onChanged: () => () => {}, - register: () => () => {}, - }) - ctx.provide('userQuestions', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) - return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent } + return { api, getAgent, listChildren, followup, interrupt, parent } } describe('subagent gateway', () => { @@ -150,90 +107,7 @@ describe('subagent gateway', () => { .toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } }) }) - it('reads a healthy direct child without looking up or activating any Agent', async () => { - const { api, getAgent, inspect, restore } = bench() - const response = await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10, - })) - expect(response.result).toMatchObject({ - ok: true, - value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, - }) - expect(inspect).toHaveBeenCalledWith(CHILD) - expect(restore).toHaveBeenCalledTimes(1) - expect(getAgent).not.toHaveBeenCalled() - }) - - it('serves a live child from the in-memory snapshot and the watermark projections', async () => { - const { api, inspect, snapshot, restore } = bench({ liveChild: true }) - const response = await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - })) - expect(response.result).toMatchObject({ - ok: true, - value: { hasMore: false, projections: { asOfSeq: 3 } }, - }) - expect(snapshot).toHaveBeenCalledTimes(1) - expect(restore).not.toHaveBeenCalled() - expect(inspect).not.toHaveBeenCalled() - }) - - it('serves the page without projections when a hostile unit breaks the fold', async () => { - const cold = bench({ projectionsThrow: true }) - const coldResponse = await cold.api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - })) - expect(coldResponse.result).toMatchObject({ - ok: true, - value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, - }) - if (coldResponse.result.ok) expect('projections' in coldResponse.result.value).toBe(false) - - const live = bench({ projectionsThrow: true, liveChild: true }) - const liveResponse = await live.api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - })) - expect(liveResponse.result).toMatchObject({ - ok: true, - value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, - }) - if (liveResponse.result.ok) expect('projections' in liveResponse.result.value).toBe(false) - expect(live.snapshot).toHaveBeenCalledTimes(1) - }) - - it('reads one-shot history and rejects an address with the wrong mode', async () => { - const oneShot = { - kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch', - activity: 'inactive', hasChildren: false, - } - const { api, inspect } = bench({ entries: [oneShot] }) - expect((await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot', - }))).result).toMatchObject({ ok: true }) - expect((await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - }))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } }) - expect(inspect).toHaveBeenCalledTimes(1) - }) - - it('rejects a diagnostic address before reading history', async () => { - const { api, inspect } = bench({ entries: [ - { kind: 'diagnostic', id: CHILD, reason: 'unsupported' }, - ] }) - const response = await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - })) - expect(response.result).toMatchObject({ - ok: false, - error: { - code: 'subagent-catalog-diagnostic', - details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' }, - }, - }) - expect(inspect).not.toHaveBeenCalled() - }) - - it('maps the missing projections capability to one wire face on list, history, and prompt', async () => { + it('maps the missing projections capability to one wire face on list and prompt', async () => { const listError = () => new SubagentError( 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', @@ -247,12 +121,6 @@ describe('subagent gateway', () => { expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result) .toMatchObject({ ok: false, error: expected }) - const history = bench({ listError: listError() }) - expect((await history.api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - }))).result).toMatchObject({ ok: false, error: expected }) - expect(history.inspect).not.toHaveBeenCalled() - const prompt = bench({ listError: listError() }) expect((await prompt.api.subagents.prompt(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], @@ -332,19 +200,7 @@ describe('subagent gateway', () => { }) }) - it('maps history disappearance and hides unexpected backend details', async () => { - const disappeared = bench({ storedChild: false }) - expect((await disappeared.api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - }))).result).toMatchObject({ - ok: false, - error: { - code: 'subagent-not-found', - message: 'subagent disappeared during history read', - details: { parentSessionId: PARENT, childSessionId: CHILD }, - }, - }) - + it('hides unexpected backend details', async () => { const catalog = bench({ listError: new Error('secret descriptor') }) expect((await catalog.api.subagents.list(request({ parentSessionId: PARENT, @@ -363,18 +219,17 @@ describe('subagent gateway', () => { }) it('interrupts through the core primitive alone while the parent Agent is offline', async () => { - const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false }) + const { api, interrupt, getAgent, listChildren } = bench({ parentLive: false }) const response = await api.subagents.interrupt(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const, })) expect(response.rpcId).toBe('subagent-rpc') expect(response.result).toEqual({ ok: true, value: { accepted: true } }) expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT }) - // No parent-registry, catalog, or history dependency: this is what keeps a + // No parent-registry or catalog dependency: this is what keeps a // live child interruptible after its parent Agent went offline. expect(getAgent).not.toHaveBeenCalled() expect(listChildren).not.toHaveBeenCalled() - expect(inspect).not.toHaveBeenCalled() }) it('maps interrupt authorization rejection without touching other services', async () => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index de9d4ddab0..648af08e3b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -1,13 +1,13 @@ /** * Wire-protocol coverage over the isomorphic point: InProcessApiClient → * toFetchHandler(scripted impl) runs the real envelope wrap/unwrap, zod - * two-level parse, rpcId discipline, and SSE framing with no network and no - * browser. Each case scripts its own minimal ApiProxy. + * two-level parse, and rpcId discipline with no network or browser. Each case + * scripts its own minimal ApiProxy. */ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { ApiProxy, GoalRef, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy' +import type { ApiProxy, GoalRef, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy' import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' const sid = (id: string): SessionId => id as SessionId @@ -18,54 +18,20 @@ function ok(request: RpcRequest, value: T): Promise> /** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */ function scriptedApi(overrides: { - sessions?: Partial subagents?: Partial host?: Partial skills?: Partial agentPresets?: Partial - events?: Partial goals?: Partial settings?: Partial credentials?: Partial llm?: Partial - respond?: ApiProxy['respond'] } = {}): ApiProxy { - async function *empty(): AsyncGenerator> { /* no frames */ } const err = (r: RpcRequest): Promise> => Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } }) return { - sessions: { - list: r => ok(r, { items: [] }), - search: r => ok(r, { items: [], hasMore: false }), - create: r => ok(r, { sessionId: sid('s-new') }), - history: r => ok(r, { - events: [], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - models: r => ok(r, { - current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - routable: true, - groups: [], - failures: [], - }), - selectModel: r => ok(r, { - selected: { provider: r.payload.provider, model: r.payload.model }, - }), - rename: r => ok(r, { title: 'renamed', seq: 0 }), - fork: r => ok(r, { sessionId: sid('s-fork') }), - prompt: r => ok(r, { accepted: true as const }), - attachment: r => ok(r, { - attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, - data: 'AA==', - }), - updateQueue: r => ok(r, { accepted: true as const }), - cancel: r => ok(r, { accepted: true as const }), - ...overrides.sessions, - }, subagents: { list: r => ok(r, { entries: [], parentAvailable: false }), - history: r => ok(r, { events: [], hasMore: false }), prompt: r => ok(r, { messageId: 'message-1' as never }), interrupt: r => ok(r, { accepted: true as const }), ...overrides.subagents, @@ -80,15 +46,6 @@ function scriptedApi(overrides: { openPath: r => ok(r, { opened: true as const }), ...overrides.host, }, - workspace: { - list: r => ok(r, { items: [], archivedSessionIds: [] }), - create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), - rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), - delete: r => ok(r, { deleted: true as const }), - insertBefore: r => ok(r, { workspaceIds: [r.payload.workspaceId] }), - insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), - archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), - }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, agentPresets: { list: r => ok(r, { presets: [], authorable: false, hasDocument: false }), @@ -128,8 +85,6 @@ function scriptedApi(overrides: { discoverModels: err, ...overrides.llm, }, - events: { mux: () => empty(), host: () => empty(), ...overrides.events }, - respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), downloads: { sessionLog: async () => new Response('stub', { status: 404 }) }, } } @@ -149,94 +104,20 @@ function recorderInto(seen: { method: string; payload: unknown }[]) { describe('unary round trip', () => { it('carries payload out and value back through the full wire form', async () => { - let seen: RpcRequest<{ cursor?: string }> | undefined + let seen: RpcRequest<{}> | undefined const api = scriptedApi({ - sessions: { - list: (r) => { - seen = r - return ok(r, { items: [{ sessionId: sid('s1'), updatedAt: 7, running: false, blank: false }] }) + host: { + describe: (request) => { + seen = request + return ok(request, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true }) }, }, }) - const response = await client(api).sessions.list({ cursor: 'c1' }) - // Impl received the narrow form with a minted id; client returned the same id and value. - expect(seen?.payload).toEqual({ cursor: 'c1' }) + const response = await client(api).host.describe({}) + expect(seen?.payload).toEqual({}) expect(seen?.rpcId).toBeTruthy() expect(response.rpcId).toBe(seen?.rpcId) - expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } }) - }) - - it('round-trips a trimmed session search query and its bounded result metadata', async () => { - let seen: RpcRequest<{ query: string }> | undefined - const api = scriptedApi({ - sessions: { - search: (request) => { - seen = request - return ok(request, { - items: [{ sessionId: sid('s1'), snippet: 'matching message text' }], - hasMore: true, - }) - }, - }, - }) - const response = await client(api).sessions.search({ query: ' message text ' }) - expect(seen?.payload).toEqual({ query: 'message text' }) - expect(response.result).toEqual({ - ok: true, - value: { - items: [{ sessionId: 's1', snippet: 'matching message text' }], - hasMore: true, - }, - }) - }) - - it('rejects an overlong session-search snippet at the client value boundary', async () => { - const api = scriptedApi({ - sessions: { - search: request => ok(request, { - items: [{ sessionId: sid('s1'), snippet: '😀'.repeat(241) }], - hasMore: false, - }), - }, - }) - - await expect(client(api).sessions.search({ query: 'message' })) - .rejects.toThrow(/240 Unicode code points/) - }) - - it('routes session fork with its optional cut anchor through the wire', async () => { - let seen: RpcRequest<{ sessionId: SessionId; atSeq?: number }> | undefined - const api = scriptedApi({ - sessions: { - fork: (request) => { - seen = request - return ok(request, { sessionId: sid('s-child') }) - }, - }, - }) - const response = await client(api).sessions.fork({ sessionId: sid('s-parent'), atSeq: 7 }) - expect(seen?.payload).toEqual({ sessionId: 's-parent', atSeq: 7 }) - expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } }) - }) - - it('routes workspace rename, delete, and ordering through the wire', async () => { - const api = scriptedApi() - const c = client(api) - const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) - expect(renamed.result.ok).toBe(true) - const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' }) - expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) - const deleted = await c.workspace.delete({ workspaceId: 'w1' as never }) - expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) - const workspaceOrder = await c.workspace.insertBefore({ - workspaceId: 'w1' as never, - beforeWorkspaceId: 'w2' as never, - }) - expect(workspaceOrder.result).toEqual({ ok: true, value: { workspaceIds: ['w1'] } }) - const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) - expect(anchored.result.ok).toBe(true) - const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) - expect(appended.result.ok).toBe(true) + expect(response.result).toMatchObject({ ok: true, value: { version: '0-test' } }) }) it('routes the agent-preset roster and switch through the wire', async () => { @@ -253,29 +134,27 @@ describe('unary round trip', () => { it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ - sessions: { - cancel: r => Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: sid('sx') } } } }), + host: { + describe: request => Promise.resolve({ + rpcId: request.rpcId, + result: { ok: false, error: { code: 'internal', message: 'nope', details: {} } }, + }), }, }) - const response = await client(api).sessions.cancel({ sessionId: sid('sx') }) - expect(response.result).toEqual({ ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: 'sx' } } }) + const response = await client(api).host.describe({}) + expect(response.result).toEqual({ ok: false, error: { code: 'internal', message: 'nope', details: {} } }) }) it('throws on rpcId echo mismatch', async () => { const api = scriptedApi({ - sessions: { list: () => Promise.resolve({ rpcId: RpcId('forged'), result: { ok: true, value: { items: [] } } }) }, + host: { + describe: () => Promise.resolve({ + rpcId: RpcId('forged'), + result: { ok: true, value: { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true } }, + }), + }, }) - await expect(client(api).sessions.list({})).rejects.toThrow(/rpcId mismatch/) - }) - - it('rejects an invalid payload at the handler as 200 + bad-request with issues', async () => { - const api = scriptedApi() - const response = await client(api).sessions.history({ sessionId: 123 as unknown as SessionId }) - expect(response.result.ok).toBe(false) - if (!response.result.ok) { - expect(response.result.error.code).toBe('bad-request') - expect((response.result.error.details as { issues: unknown[] }).issues.length).toBeGreaterThan(0) - } + await expect(client(api).host.describe({})).rejects.toThrow(/rpcId mismatch/) }) it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => { @@ -306,8 +185,8 @@ describe('unary round trip', () => { it('rejects a method/path mismatch as bad-request', async () => { const handler = toFetchHandler(scriptedApi()) - const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} } - const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) + const body = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} } + const response = await handler.fetch('http://dsh.internal/api/skill.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) expect(response.status).toBe(200) const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } } expect(parsed.result.ok).toBe(false) @@ -318,13 +197,13 @@ describe('unary round trip', () => { it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => { const handler = toFetchHandler(scriptedApi()) // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse. - const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) }) + const noId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) }) expect(noId.status).toBe(200) const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } } expect(noIdParsed.result.ok).toBe(false) expect(noIdParsed.rpcId).toBe('invalid-request') // A string rpcId in the otherwise-bad body is salvaged for correlation. - const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) + const withId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } } expect(withIdParsed.result.ok).toBe(false) expect(withIdParsed.rpcId).toBe('salvage-me') @@ -336,29 +215,31 @@ describe('unary round trip', () => { const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) expect(notFound.status).toBe(404) // Non-JSON body → 400. - const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }) + const badBody = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }) expect(badBody.status).toBe(400) // Impl crash → 500, and through the client that is a throw, not an err result. - const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } }) - await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/) + const crashing = scriptedApi({ host: { describe: () => { throw new Error('impl exploded') } } }) + await expect(client(crashing).host.describe({})).rejects.toThrow(/transport failure .*500/) }) it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => { - const list = vi.fn((r: RpcRequest<{}>) => ok(r, { items: [] })) - const handler = toFetchHandler(scriptedApi({ sessions: { list } })) - const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} }) + const describe = vi.fn((request: RpcRequest<{}>) => ok(request, { + version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, + })) + const handler = toFetchHandler(scriptedApi({ host: { describe } })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} }) // A "simple" browser POST (text/plain — sent with no CORS preflight) is // refused at the carrier before the impl runs. - const plain = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'text/plain' }, body }) + const plain = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'text/plain' }, body }) expect(plain.status).toBe(415) // A string body with no explicit header defaults to text/plain — same fence. - const unlabelled = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body }) + const unlabelled = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', body }) expect(unlabelled.status).toBe(415) - expect(list).not.toHaveBeenCalled() + expect(describe).not.toHaveBeenCalled() // Media-type parameters pass: the fence checks the type, not the exact string. - const charset = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body }) + const charset = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body }) expect(charset.status).toBe(200) - expect(list).toHaveBeenCalledTimes(1) + expect(describe).toHaveBeenCalledTimes(1) }) it('rejects when the transport never resolves within timeoutMs', async () => { @@ -368,7 +249,7 @@ describe('unary round trip', () => { init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')) }) }), }, 25) - await expect(never.sessions.list({})).rejects.toThrow() + await expect(never.host.describe({})).rejects.toThrow() }) it('aborts a unary call through the caller-supplied external signal', async () => { @@ -376,7 +257,7 @@ describe('unary round trip', () => { // works even when the transport ignores the signal entirely (hung impl). const gate = new AbortController() const hung = new InProcessApiClient({ fetch: () => new Promise(() => {}) }, 60_000) - const call = hung.sessions.list({}, gate.signal) + const call = hung.host.describe({}, gate.signal) gate.abort(new Error('externally aborted')) await expect(call).rejects.toThrow(/externally aborted/) }) @@ -391,14 +272,14 @@ describe('unary round trip', () => { }, 60_000) const gate = new AbortController() gate.abort('gone before start') - await expect(c.sessions.list({}, gate.signal)).rejects.toThrow('gone before start') + await expect(c.host.describe({}, gate.signal)).rejects.toThrow('gone before start') expect(touched).toBe(false) }) it('maps a non-Error, non-string abort reason to the default AbortError message', async () => { const gate = new AbortController() const hung = new InProcessApiClient({ fetch: () => new Promise(() => {}) }, 60_000) - const call = hung.sessions.list({}, gate.signal) + const call = hung.host.describe({}, gate.signal) gate.abort(42) await expect(call).rejects.toThrow('This operation was aborted') }) @@ -417,179 +298,9 @@ describe('unary round trip', () => { it('throws on an S→C ok value that fails the method value schema (second-level parse)', async () => { // Impl echoes rpcId but returns a wrong-shaped value: envelope parse passes, value parse must reject. const api = scriptedApi({ - sessions: { list: r => Promise.resolve({ rpcId: r.rpcId, result: { ok: true, value: { items: 'not-an-array' } } }) as never }, + host: { describe: request => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { version: 1 } } }) as never }, }) - await expect(client(api).sessions.list({})).rejects.toThrow() - }) -}) - -describe('workspace domain round trip', () => { - it('routes both workspace methods through their handler rows and value schemas', async () => { - const c = client(scriptedApi()) - const list = await c.workspace.list({}) - expect(list.result).toEqual({ ok: true, value: { items: [], archivedSessionIds: [] } }) - const created = await c.workspace.create({ path: '/t' }) - expect(created.result.ok).toBe(true) - if (created.result.ok) expect(created.result.value.created).toBe(true) - const archivedResponse = await c.workspace.archiveSession({ sessionId: 's-arch' as never }) - expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) - }) - - it('rejects a pathless create payload at the handler schema', async () => { - const response = await client(scriptedApi()).workspace.create({} as never) - expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') - }) -}) - -describe('SSE stream path', () => { - it('yields frames in order and skips the comment preamble', async () => { - const frames: MuxFrame[] = [ - { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 3 }, - { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } }, - ] - const api = scriptedApi({ - events: { - async *mux(request) { - let n = 0 - for (const frame of frames) yield { rpcId: RpcId(`push-${n++}-${request.rpcId}`), payload: frame } - }, - }, - }) - const seen: MuxFrame[] = [] - for await (const envelope of client(api).events.mux({}, new AbortController().signal)) { - seen.push(envelope.payload) - } - expect(seen).toEqual(frames) - }) - - it('reassembles frames across arbitrary chunk boundaries', async () => { - // Two SSE frames split so one frame spans chunks and one chunk carries parts of both. - const f1 = { type: 'server-request', rpcId: 'a', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's1', lastSeq: 1 } } - const f2 = { type: 'server-request', rpcId: 'b', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's2', lastSeq: 2 } } - const wire = `: connected\n\ndata: ${JSON.stringify(f1)}\n\ndata: ${JSON.stringify(f2)}\n\n` - const cuts = [5, 40, wire.indexOf('data: ', 40) + 3] - const encoder = new TextEncoder() - const doFetch = (): Promise => Promise.resolve(new Response(new ReadableStream({ - start(controller) { - let prev = 0 - for (const cut of [...cuts, wire.length]) { - controller.enqueue(encoder.encode(wire.slice(prev, cut))) - prev = cut - } - controller.close() - }, - }), { status: 200 })) - const chopped = new InProcessApiClient({ fetch: doFetch }) - const seen: string[] = [] - for await (const envelope of chopped.events.mux({}, new AbortController().signal)) { - seen.push((envelope.payload as { sessionId: string }).sessionId) - expect(envelope.rpcId).toBe(seen.length === 1 ? 'a' : 'b') - } - expect(seen).toEqual(['s1', 's2']) - }) - - it('emits a stream/error frame then closes when the impl throws mid-stream', async () => { - const api = scriptedApi({ - events: { - async *host(request): AsyncGenerator> { - yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'host/session-added', sessionId: sid('s1'), blank: true } } - throw new Error('impl died mid-stream') - }, - }, - }) - const seen: HostFrame[] = [] - for await (const envelope of client(api).events.host({}, new AbortController().signal)) { - seen.push(envelope.payload) - } - expect(seen.map(f => f.type)).toEqual(['host/session-added', 'stream/error']) - const last = seen.at(-1) - if (last?.type === 'stream/error') expect(last.error.message).toMatch(/impl died mid-stream/) - }) - - it('drops a malformed SSE frame and keeps the stream alive (S→C two-level parse)', async () => { - const good = { type: 'server-request', rpcId: 'g1', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's1', lastSeq: 1 } } - const badEnvelope = { type: 'server-response', rpcId: 'x' } // wrong quadrant for a stream - const badFrame = { type: 'server-request', rpcId: 'b1', method: 'nope', payload: { type: 'no/such-frame' } } - const wire = [ - 'data: {oops', // not JSON - `data: ${JSON.stringify(badEnvelope)}`, - `data: ${JSON.stringify(badFrame)}`, - `data: ${JSON.stringify(good)}`, - ].map(l => `${l}\n\n`).join('') - const doFetch = (): Promise => Promise.resolve(new Response(new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(wire)) - controller.close() - }, - }), { status: 200 })) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - const seen: MuxFrame[] = [] - for await (const envelope of new InProcessApiClient({ fetch: doFetch }).events.mux({}, new AbortController().signal)) { - seen.push(envelope.payload) - } - // The three corrupt frames are reported and skipped; the good one still arrives. - expect(seen).toEqual([{ type: 'session/subscribed', sessionId: 's1', lastSeq: 1 }]) - expect(errorSpy.mock.calls.length).toBe(3) - } finally { - errorSpy.mockRestore() - } - }) - - it('fires onOpen once headers are in, before the first frame, and not on transport failure', async () => { - const api = scriptedApi({ - events: { - async *mux(request): AsyncGenerator> { - yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 0 } } - }, - }, - }) - const order: string[] = [] - const iterator = client(api).events.mux({}, new AbortController().signal, () => order.push('open')) - expect(order).toEqual([]) // lazy generator: no fetch (and no onOpen) before iteration - for await (const _ of iterator) order.push('frame') - expect(order).toEqual(['open', 'frame']) - - // Transport failure path: onOpen must not fire. - const failing = new InProcessApiClient({ fetch: () => Promise.resolve(new Response('down', { status: 503 })) }) - const failOrder: string[] = [] - await expect((async () => { - for await (const _ of failing.events.mux({}, new AbortController().signal, () => failOrder.push('open'))) { /* unreachable */ } - })()).rejects.toThrow(/transport failure/) - expect(failOrder).toEqual([]) - }) - - it('stops consuming when the caller aborts', async () => { - let implSawAbort = false - const api = scriptedApi({ - events: { - async *mux(_request, signal): AsyncGenerator> { - try { - let n = 0 - while (true) { - yield { rpcId: RpcId(`p${n}`), payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: n++ } } - await new Promise(resolve => setTimeout(resolve, 5)) - if (signal.aborted) return - } - } finally { - implSawAbort = true - } - }, - }, - }) - const abort = new AbortController() - let count = 0 - // In-process abort ends the stream (impl returns on signal.aborted); over a real - // network fetch the same abort surfaces as a rejection — both stop the loop. - await (async () => { - for await (const _ of client(api).events.mux({}, abort.signal)) { - if (++count === 2) abort.abort() - } - })().catch(() => undefined) - expect(count).toBe(2) - // Generator teardown may lag the abort by a microtask; poll briefly. - await vi.waitFor(() => { expect(implSawAbort).toBe(true) }) + await expect(client(api).host.describe({})).rejects.toThrow() }) }) @@ -650,36 +361,13 @@ describe('goals unary surface', () => { }) }) -describe('respond path', () => { - it('round-trips a client-response to a receipt', async () => { - const seen: unknown[] = [] - const api = scriptedApi({ - respond: (message) => { - seen.push(message) - return Promise.resolve({ accepted: true as const }) - }, - }) - const receipt = await client(api).respond({ type: 'client-response', rpcId: RpcId('req-1'), result: { ok: true, value: { behavior: 'allow' } } }) - expect(receipt).toEqual({ accepted: true }) - expect(seen).toEqual([{ type: 'client-response', rpcId: 'req-1', result: { ok: true, value: { behavior: 'allow' } } }]) - }) - - it('returns bad-response for a malformed client-response without reaching the impl', async () => { - const respond = vi.fn() - const handler = toFetchHandler(scriptedApi({ respond })) - const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response' }) }) - expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' }) - expect(respond).not.toHaveBeenCalled() - }) -}) - describe('envelope tap', () => { it('delivers one microtask batch of full forms per unary call', async () => { const api = scriptedApi() const tapped = client(api) const batches: (readonly RpcMessage[])[] = [] tapped.subscribeEnvelopes(batch => batches.push(batch)) - await tapped.sessions.list({}) + await tapped.host.describe({}) await vi.waitFor(() => { expect(batches.length).toBeGreaterThan(0) }) const all = batches.flat() expect(all.map(m => m.type)).toEqual(['client-request', 'server-response']) @@ -694,7 +382,7 @@ describe('envelope tap', () => { const good: string[] = [] tapped.subscribeEnvelopes(() => { throw new Error('listener bug') }) tapped.subscribeEnvelopes(batch => good.push(...batch.map(m => m.type))) - const response = await tapped.sessions.list({}) + const response = await tapped.host.describe({}) expect(response.result.ok).toBe(true) await vi.waitFor(() => { expect(good).toContain('server-response') }) } finally { @@ -705,11 +393,11 @@ describe('envelope tap', () => { it('buffers nothing with zero subscribers and unsubscribes cleanly', async () => { const api = scriptedApi() const tapped = client(api) - await tapped.sessions.list({}) // no subscribers: must not accumulate + await tapped.host.describe({}) // no subscribers: must not accumulate const batches: (readonly RpcMessage[])[] = [] const unsubscribe = tapped.subscribeEnvelopes(batch => batches.push(batch)) unsubscribe() - await tapped.sessions.list({}) + await tapped.host.describe({}) await new Promise(resolve => setTimeout(resolve, 0)) expect(batches).toEqual([]) }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 77432c55af..a512edd7ce 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,121 +1,16 @@ import { describe, expect, it, vi } from 'vitest' -import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' -import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' -import { RpcId } from '../src/api/rpc.ts' +import type { ApiProxy } from '../src/api/index.ts' +import type { RpcMessage, RpcRequest } from '../src/api/rpc.ts' import { toFetchHandler } from '../src/fetch/handler.ts' import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts' -/** Minimal in-memory ApiProxy: echoes rpcIds, scripts one frame per stream. */ -function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFrame[]; crashOn: string }> = {}): ApiProxy { - const muxFrames = overrides.muxFrames ?? [{ type: 'session/subscribed', sessionId: 's1' as never, lastSeq: -1 }] - const hostFrames = overrides.hostFrames ?? [{ type: 'host/session-removed', sessionId: 's1' as never }] - async function * stream(frames: F[], signal: AbortSignal): AsyncGenerator> { - for (const payload of frames) { - if (signal.aborted) return - yield { rpcId: RpcId(`frame-${String(frames.indexOf(payload))}`), payload } - } - } +/** Minimal in-memory ApiProxy that echoes rpcIds. */ +function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { return { - sessions: { - async list(request) { - if (overrides.crashOn === 'session.list') throw new Error('impl crashed') - return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } - }, - async search(request, signal) { - if (request.payload.query === 'hang') { - if (!signal.aborted) { - await new Promise((resolve) => { - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) - } - return { - rpcId: request.rpcId, - result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } }, - } - } - return { - rpcId: request.rpcId, - result: { - ok: true, - value: { items: [{ sessionId: 's1' as never, snippet: 'fixture match' }], hasMore: false }, - }, - } - }, - async create(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } } - }, - async history(request) { - if (request.payload.sessionId === ('with-projections' as never)) { - return { - rpcId: request.rpcId, - result: { ok: true, value: { events: [], hasMore: false, projections: { asOfSeq: 9, values: { todos: [{ content: 'current', status: 'in_progress' as const }] } } } }, - } - } - return { - rpcId: request.rpcId, - result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } }, - } - }, - async models(request) { - return { - rpcId: request.rpcId, - result: { - ok: true, - value: { - current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - routable: true, - groups: [], - failures: [], - }, - }, - } - }, - async selectModel(request) { - return { - rpcId: request.rpcId, - result: { - ok: true, - value: { - selected: { - provider: request.payload.provider, - model: request.payload.model, - ...request.payload.reasoningEffort === undefined - ? {} - : { reasoningEffort: request.payload.reasoningEffort }, - }, - }, - }, - } - }, - async rename(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } } - }, - async fork(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-fork' as never } } } - }, - async prompt(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } - }, - async attachment(request) { - return { - rpcId: request.rpcId, - result: { ok: true, value: { attachment: { attachmentId: 'a' as never, mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, data: 'AA==' } }, - } - }, - async updateQueue(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } - }, - async cancel(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } - }, - }, subagents: { async list(request) { return { rpcId: request.rpcId, result: { ok: true, value: { entries: [], parentAvailable: false } } } }, - async history(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { events: [], hasMore: false } } } - }, async prompt(request, signal) { if (request.payload.content.some(block => block.type === 'text' && block.text === 'hang')) { if (!signal.aborted) { @@ -139,6 +34,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, host: { async describe(request) { + if (overrides.crashOn === 'host.describe') throw new Error('impl crashed') return { rpcId: request.rpcId, result: { @@ -160,38 +56,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } }, }, - workspace: { - async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { items: [], archivedSessionIds: [] } } } - }, - async create(request) { - return { - rpcId: request.rpcId, - result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } }, - } - }, - async rename(request) { - return { - rpcId: request.rpcId, - result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, - } - }, - async delete(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } } - }, - async insertBefore(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { workspaceIds: [request.payload.workspaceId] } } } - }, - async insertSessionBefore(request) { - return { - rpcId: request.rpcId, - result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, - } - }, - async archiveSession(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } } - }, - }, agentPresets: { list(request: RpcRequest<{}>) { return Promise.resolve({ @@ -282,13 +146,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } } }, }, - events: { - mux: (_request, signal) => stream(muxFrames, signal), - host: (_request, signal) => stream(hostFrames, signal), - }, - async respond(message: ClientResponse): Promise { - return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' } - }, downloads: { async sessionLog() { return new Response('stub', { status: 404 }) @@ -301,70 +158,17 @@ function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClie return new InProcessApiClient(toFetchHandler(api), timeoutMs) } -async function collect(stream: AsyncIterable>): Promise[]> { - const out: RpcRequest[] = [] - for await (const envelope of stream) out.push(envelope) - return out -} - describe('unary round trip (handler ⇄ client, no network)', () => { it('carries a success result and echoes the minted rpcId', async () => { - const response = await client().sessions.list({}) - expect(response.result).toEqual({ ok: true, value: { items: [] } }) + const response = await client().host.describe({}) + expect(response.result).toMatchObject({ ok: true, value: { version: 'v', cwd: '/w' } }) expect(response.rpcId).toMatch(/[0-9a-f-]{36}/) }) - it('carries the tail-page projections block through the wire schema (Zod must not strip it)', async () => { - const response = await client().sessions.history({ sessionId: 'with-projections' as never }) - expect(response.result.ok).toBe(true) - if (response.result.ok) { - expect(response.result.value.projections).toEqual( - { asOfSeq: 9, values: { todos: [{ content: 'current', status: 'in_progress' }] } }, - ) - } - }) - it('carries a business error as 200 + error result', async () => { - const response = await client().sessions.history({ sessionId: 'missing' as never }) + const response = await client().settings.update({ ns: 'test', patch: {} }) expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found') - }) - - it('covers create/prompt/updateQueue/cancel/describe passthrough', async () => { - const c = client() - expect((await c.sessions.search({ query: 'fixture' })).result).toEqual({ - ok: true, - value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false }, - }) - expect((await c.sessions.create({})).result.ok).toBe(true) - expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true) - const selected = await c.sessions.selectModel({ - sessionId: 's' as never, - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - reasoningEffort: 'max', - }) - expect(selected.result).toMatchObject({ - ok: true, - value: { - selected: { - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - reasoningEffort: 'max', - }, - }, - }) - const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' }) - expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } }) - expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) - expect((await c.sessions.attachment({ sessionId: 's' as never, attachmentId: 'a' as never })).result.ok).toBe(true) - expect((await c.sessions.updateQueue({ - sessionId: 's' as never, - itemId: 'item-1' as never, - action: { kind: 'remove' }, - })).result.ok).toBe(true) - expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) - expect((await c.host.describe({})).result.ok).toBe(true) + if (!response.result.ok) expect(response.result.error.code).toBe('settings-rejected') }) it('round-trips every agent-preset method, authoring included', async () => { @@ -465,11 +269,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const c = client() expect((await c.subagents.list({ parentSessionId: 'parent' as never })).result) .toEqual({ ok: true, value: { entries: [], parentAvailable: false } }) - expect((await c.subagents.history({ - parentSessionId: 'parent' as never, - childSessionId: 'child' as never, - mode: 'one-shot', - })).result).toEqual({ ok: true, value: { events: [], hasMore: false } }) expect((await c.subagents.prompt({ parentSessionId: 'parent' as never, childSessionId: 'child' as never, @@ -508,29 +307,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(handlerSignal.aborted).toBe(true) }) - it('propagates the carrier Request signal into session.search', async () => { - const handler = toFetchHandler(fakeApi()) - const controller = new AbortController() - const body = JSON.stringify({ - type: 'client-request', - rpcId: 'r-search-sig', - method: 'session.search', - payload: { query: 'hang' }, - }) - const pending = handler.fetch(new Request( - 'http://x/api/session.search', - { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal }, - )) - controller.abort() - const response = await pending - const parsed = await response.json() as { - rpcId: string - result: { error?: { code: string } } - } - expect(parsed.rpcId).toBe('r-search-sig') - expect(parsed.result.error?.code).toBe('cancelled') - }) - it('propagates the carrier Request signal into subagent.prompt', async () => { const handler = toFetchHandler(fakeApi()) const controller = new AbortController() @@ -589,17 +365,17 @@ describe('handler carrier-layer statuses', () => { it('404s unknown paths and non-POST non-stream methods', async () => { expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404) - expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404) + expect((await handler.fetch(new Request('http://x/api/host.describe', { method: 'GET' }))).status).toBe(404) expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404) }) it('400s a non-JSON body', async () => { - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' })) + const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' })) expect(response.status).toBe(400) }) it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => { - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) })) + const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) })) expect(response.status).toBe(200) const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } expect(body.rpcId).toBe('invalid-request') @@ -607,125 +383,51 @@ describe('handler carrier-layer statuses', () => { }) it('rejects a method/path mismatch echoing the envelope rpcId', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'host.describe', payload: {} }) + const response = await handler.fetch(new Request('http://x/api/skill.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } } expect(parsed.rpcId).toBe('r-9') expect(parsed.result.error?.message).toContain('does not match path') }) it('rejects an invalid payload with the zod issues attached', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'host.openPath', payload: {} }) + const response = await handler.fetch(new Request('http://x/api/host.openPath', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } } expect(parsed.result.error?.code).toBe('bad-request') expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0) }) it('500s when the impl itself throws', async () => { - const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' })) - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} }) - const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) + const crashing = toFetchHandler(fakeApi({ crashOn: 'host.describe' })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'host.describe', payload: {} }) + const response = await crashing.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) expect(response.status).toBe(500) expect(await response.text()).toContain('impl crashed') }) - it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => { - const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } }) - const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: good }))).json() - expect(goodReceipt).toEqual({ accepted: true }) - const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} }) - const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: bad }))).json() - expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' }) - }) - it('accepts (url, init) form fetch invocation', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} }) - const response = await handler.fetch('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'host.describe', payload: {} }) + const response = await handler.fetch('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body }) expect(response.status).toBe(200) }) }) -describe('SSE streams through the carrier', () => { - it('yields mux frames as ServerRequest narrow forms and completes', async () => { - const ac = new AbortController() - const frames = await collect(client().events.mux({}, ac.signal)) - expect(frames).toHaveLength(1) - expect(frames[0]?.payload).toMatchObject({ type: 'session/subscribed' }) - expect(frames[0]?.rpcId).toBe('frame-0') - }) - - it('yields host frames', async () => { - const ac = new AbortController() - const frames = await collect(client().events.host({}, ac.signal)) - expect(frames[0]?.payload).toMatchObject({ type: 'host/session-removed' }) - }) - - it('drops frames after the consumer aborts mid-stream', async () => { - const many = Array.from({ length: 50 }, (_, i): MuxFrame => ({ type: 'session/subscribed', sessionId: `s${String(i)}` as never, lastSeq: i })) - const ac = new AbortController() - const received: RpcRequest[] = [] - for await (const envelope of client(fakeApi({ muxFrames: many })).events.mux({}, ac.signal)) { - received.push(envelope) - if (received.length === 2) break // generator return → reader.cancel path - } - expect(received).toHaveLength(2) - }) - - it('swallows a reader.cancel rejection on early exit', async () => { - const encoder = new TextEncoder() - const body = new ReadableStream({ - start(controller) { - const frame = { type: 'server-request', rpcId: 'f0', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's', lastSeq: -1 } } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`)) - // stream intentionally left open: the consumer breaks first - }, - cancel() { - throw new Error('cancel refused') - }, - }) - const c = new InProcessApiClient({ fetch: async () => new Response(body, { headers: { 'content-type': 'text/event-stream' } }) }) - const received: RpcRequest[] = [] - for await (const envelope of c.events.mux({}, new AbortController().signal)) { - received.push(envelope) - break - } - expect(received).toHaveLength(1) - }) - - it('surfaces a mid-stream impl failure as one stream/error frame, then the stream ends', async () => { - const api = fakeApi() - api.events.mux = (_request, _signal) => (async function * (): AsyncGenerator> { - yield { rpcId: RpcId('f0'), payload: { type: 'session/subscribed', sessionId: 's' as never, lastSeq: -1 } } - throw new Error('stream source died') - })() - const frames = await collect(client(api).events.mux({}, new AbortController().signal)) - expect(frames).toHaveLength(2) - expect(frames[1]?.payload).toMatchObject({ type: 'stream/error', error: { code: 'internal' } }) - }) -}) - -describe('client respond and transport failures', () => { - it('passes a client-response through and parses the receipt', async () => { - const receipt = await client().respond({ type: 'client-response', rpcId: RpcId('known'), result: { ok: true, value: null } }) - expect(receipt).toEqual({ accepted: true }) - const late = await client().respond({ type: 'client-response', rpcId: RpcId('late'), result: { ok: true, value: null } }) - expect(late).toEqual({ accepted: false, reason: 'not-pending' }) - }) - - it('throws on non-OK unary and respond and stream transport', async () => { +describe('client transport failures', () => { + it('throws on a non-OK unary transport', async () => { const broken = new InProcessApiClient({ fetch: async () => new Response('down', { status: 503 }) }) - await expect(broken.sessions.list({})).rejects.toThrow('transport failure for /api/session.list: HTTP 503') - await expect(broken.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })) - .rejects.toThrow('transport failure for /api/respond') - await expect(collect(broken.events.mux({}, new AbortController().signal))).rejects.toThrow('transport failure for /api/events.mux') + await expect(broken.host.describe({})).rejects.toThrow('transport failure for /api/host.describe: HTTP 503') }) it('throws on an rpcId echo mismatch', async () => { const lying = new InProcessApiClient({ - fetch: async () => Response.json({ type: 'server-response', rpcId: 'someone-else', result: { ok: true, value: { items: [] } } }), + fetch: async () => Response.json({ + type: 'server-response', + rpcId: 'someone-else', + result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true } }, + }), }) - await expect(lying.sessions.list({})).rejects.toThrow('rpcId mismatch') + await expect(lying.host.describe({})).rejects.toThrow('rpcId mismatch') }) }) @@ -736,7 +438,7 @@ describe('envelope observation', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const unsubscribeThrowing = c.subscribeEnvelopes(() => { throw new Error('observer bug') }) const unsubscribe = c.subscribeEnvelopes((batch) => { batches.push(batch) }) - await c.sessions.list({}) + await c.host.describe({}) await new Promise((resolve) => { setTimeout(resolve, 0) }) // request and response tap in separate microtask windows (the await between // them yields), so both arrive but batch count is timing-defined @@ -752,7 +454,7 @@ describe('envelope observation', () => { const seen: RpcMessage[] = [] const unsubscribe = c.subscribeEnvelopes((batch) => { seen.push(...batch) }) unsubscribe() - await c.sessions.list({}) + await c.host.describe({}) await new Promise((resolve) => { setTimeout(resolve, 0) }) expect(seen).toHaveLength(0) }) @@ -761,7 +463,7 @@ describe('envelope observation', () => { const c = client() const batches: (readonly RpcMessage[])[] = [] c.subscribeEnvelopes((batch) => { batches.push(batch) }) - await Promise.all([c.sessions.list({}), c.host.describe({})]) + await Promise.all([c.host.describe({}), c.skills.list({ sessionId: 's1' as never })]) await new Promise((resolve) => { setTimeout(resolve, 0) }) const total = batches.reduce((n, batch) => n + batch.length, 0) expect(total).toBe(4) @@ -774,7 +476,14 @@ describe('resolveBase', () => { urls: string[] = [] protected async doFetch(input: URL): Promise { this.urls.push(input.href) - return Response.json({ type: 'server-response', rpcId: this.lastMinted, result: { ok: true, value: { items: [] } } }) + return Response.json({ + type: 'server-response', + rpcId: this.lastMinted, + result: { + ok: true, + value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true }, + }, + }) } lastMinted = '' @@ -785,18 +494,18 @@ describe('resolveBase', () => { } } const probe = new Probe() - await probe.sessions.list({}) + await probe.host.describe({}) expect(probe.urls[0]).toMatch(/^http:\/\/dsh\.internal\//) const globalWithLocation = globalThis as { location?: { origin?: string } } globalWithLocation.location = { origin: 'http://host.example' } try { const probe2 = new Probe() - await probe2.sessions.list({}) + await probe2.host.describe({}) expect(probe2.urls[0]).toMatch(/^http:\/\/host\.example\//) globalWithLocation.location = { origin: 'null' } // sandboxed iframe shape const probe3 = new Probe() - await probe3.sessions.list({}) + await probe3.host.describe({}) expect(probe3.urls[0]).toMatch(/^http:\/\/dsh\.internal\//) } finally { delete globalWithLocation.location diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 62d1a371da..6213135c6b 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -1,40 +1,19 @@ import { describe, expect, it } from 'vitest' import { RpcId, transportError } from '../src/api/rpc.ts' import { - clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, - rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema, + clientRequestSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, + rpcResultSchema, 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, - sessionSearchRequestSchema, sessionSearchValueSchema, sessionSelectModelRequestSchema, - sessionSelectModelValueSchema, sessionSummarySchema, - sessionUpdateQueueRequestSchema, sessionUpdateQueueValueSchema, -} from '../src/api/sessions.schema.ts' import { hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema, hostDescribeRequestSchema, hostDescribeValueSchema, hostListDirectoryRequestSchema, hostListDirectoryValueSchema, } from '../src/api/host.schema.ts' -import { - workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, - workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, - workspaceDeleteRequestSchema, workspaceDeleteValueSchema, - workspaceInsertBeforeRequestSchema, workspaceInsertBeforeValueSchema, - workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, - workspaceListRequestSchema, workspaceListValueSchema, - workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, -} from '../src/api/workspace.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, } from '../src/api/agent-presets.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' import { goalEditRequestSchema } from '../src/api/goals.schema.ts' import { subagentPromptRequestSchema } from '../src/api/subagents.schema.ts' @@ -60,32 +39,34 @@ describe('rpcErrorSchema', () => { 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: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone') - 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: 'directory-unreadable', message: 'm', details: { path: '/x' } }).code).toBe('directory-unreadable') + expect(rpcErrorSchema.parse({ code: 'directory-exists', message: 'm', details: { path: '/x' } }).code).toBe('directory-exists') + expect(rpcErrorSchema.parse({ code: 'directory-create-failed', message: 'm', details: { path: '/x' } }).code).toBe('directory-create-failed') + expect(rpcErrorSchema.parse({ code: 'directory-picker-unavailable', message: 'm', details: { capability: 'none' } }).code).toBe('directory-picker-unavailable') + expect(rpcErrorSchema.parse({ code: 'agent-preset-read-only', message: 'm', details: { agentPreset: 'p', reason: 'system' } }).code).toBe('agent-preset-read-only') + expect(rpcErrorSchema.parse({ code: 'agent-preset-locked', message: 'm', details: { sessionId: 's', agentPreset: 'p' } }).code).toBe('agent-preset-locked') + expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found') + expect(rpcErrorSchema.parse({ code: 'agent-preset-invalid', message: 'm', details: { agentPreset: 'p', reason: 'bad' } }).code).toBe('agent-preset-invalid') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') - expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') - expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') - expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') - expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') + expect(rpcErrorSchema.parse({ code: 'settings-rejected', message: 'm', details: { ns: 'n' } }).code).toBe('settings-rejected') + expect(rpcErrorSchema.parse({ code: 'settings-conflict', message: 'm', details: { ns: 'n', expected: 1, actual: 2 } }).code).toBe('settings-conflict') // The credentials producer still emits this code, so the branch has to stay. expect(rpcErrorSchema.parse({ code: 'credential-rejected', message: 'm', details: { ref: 'r' } }).code).toBe('credential-rejected') + expect(rpcErrorSchema.parse({ code: 'model-discovery-failed', message: 'm', details: { settingsNs: 'n' } }).code).toBe('model-discovery-failed') + expect(rpcErrorSchema.parse({ code: 'subagent-parent-unavailable', message: 'm', details: { parentSessionId: 'p' } }).code).toBe('subagent-parent-unavailable') + expect(rpcErrorSchema.parse({ code: 'subagent-not-found', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c' } }).code).toBe('subagent-not-found') + expect(rpcErrorSchema.parse({ code: 'subagent-catalog-diagnostic', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c', reason: 'corrupt' } }).code).toBe('subagent-catalog-diagnostic') + expect(rpcErrorSchema.parse({ code: 'subagent-not-resumable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-resumable') + expect(rpcErrorSchema.parse({ code: 'subagent-unauthorized', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-unauthorized') + expect(rpcErrorSchema.parse({ code: 'subagent-delivery-unavailable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-delivery-unavailable') 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: 'title-invalid', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'directory-unreadable', message: 'm', details: {} })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'internal', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() }) }) @@ -101,16 +82,12 @@ describe('rpcResultSchema', () => { }) 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: {} } + it('parses both carrier forms and the union discriminates on type', () => { + const cq = { type: 'client-request', rpcId: 'r1', method: 'host.describe', 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(clientRequestSchema.parse(cq).method).toBe('host.describe') 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() + for (const message of [cq, sr]) expect(rpcMessageSchema.parse(message)).toBeTruthy() expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow() }) @@ -125,171 +102,6 @@ describe('wire full-form schemas', () => { }) }) -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(sessionSearchRequestSchema.parse({ query: ' exact phrase ' })).toEqual({ query: 'exact phrase' }) - expect(() => sessionSearchRequestSchema.parse({ query: ' ' })).toThrow() - expect(() => sessionSearchRequestSchema.parse({ query: 'bad\0query' })).toThrow(/NUL/) - expect(() => sessionSearchRequestSchema.parse({ query: 'x'.repeat(501) })).toThrow() - expect(sessionSearchValueSchema.parse({ - items: [{ sessionId: 's1', snippet: 'matching text' }], - hasMore: true, - })).toEqual({ - items: [{ sessionId: 's1', snippet: 'matching text' }], - hasMore: true, - }) - expect(sessionSearchValueSchema.parse({ - items: [{ sessionId: 's1', snippet: '😀'.repeat(240) }], - hasMore: false, - }).items[0]?.snippet).toBe('😀'.repeat(240)) - expect(() => sessionSearchValueSchema.parse({ - items: [{ sessionId: 's1', snippet: '😀'.repeat(241) }], - hasMore: false, - })).toThrow(/240 Unicode code points/) - expect(() => sessionSearchValueSchema.parse({ - items: [{ sessionId: '', snippet: 'matching text' }], - hasMore: false, - })).toThrow() - expect(() => sessionSearchValueSchema.parse({ - items: Array.from( - { length: 21 }, - (_, index) => ({ sessionId: `s${index}`, snippet: 'matching text' }), - ), - hasMore: true, - })).toThrow() - 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, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }).hasMore).toBe(false) - expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - expect(sessionModelsValueSchema.parse({ - current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, - routable: true, - groups: [{ - id: 'deepseek-official', - name: 'DeepSeek', - models: [{ - id: 'deepseek-v4-flash', - name: 'DeepSeek V4 Flash', - description: 'fast', - 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-official', - model: 'deepseek-v4-pro', - reasoningEffort: 'max', - }).reasoningEffort).toBe('max') - expect(sessionSelectModelValueSchema.parse({ - selected: { provider: 'deepseek-official', 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-official', - model: 'm', - reasoningEffort: '', - })).toThrow() - expect(() => sessionModelsValueSchema.parse({ - current: { provider: 'deepseek-official', model: 'm' }, - groups: [{ - id: 'deepseek-official', - name: 'DeepSeek', - models: [{ id: 'm', name: 'M', reasoning: { efforts: [] } }], - }], - failures: [], - })).toThrow() - const prompt = sessionPromptRequestSchema.parse({ - sessionId: 's1', - mode: 'queue', - content: [{ type: 'text', text: 'hi' }], - clientTimeZone: 'Asia/Shanghai', - }) - expect(prompt.mode).toBe('queue') - expect(prompt.clientTimeZone).toBe('Asia/Shanghai') - expect(sessionPromptRequestSchema.parse({ - sessionId: 's1', mode: 'queue', content: [], - }).clientTimeZone).toBeUndefined() - expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow() - expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true) - // The command slot appears only when the prompt dispatched a slash command. - const dispatched = sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success', text: 'Goal set' } }) - expect(dispatched.command?.text).toBe('Goal set') - expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' }) - expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow() - expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - expect(sessionUpdateQueueRequestSchema.parse({ - sessionId: 's1', - itemId: 'i1', - action: { kind: 'edit', content: [{ type: 'text', text: 'next' }] }, - }).action.kind).toBe('edit') - expect(sessionUpdateQueueRequestSchema.parse({ - sessionId: 's1', itemId: 'i1', action: { kind: 'remove' }, - }).action.kind).toBe('remove') - expect(() => sessionUpdateQueueRequestSchema.parse({ - sessionId: 's1', itemId: 'i1', action: { kind: 'promote' }, - })).toThrow() - expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true) - expect(sessionUpdateQueueValueSchema.parse({ accepted: true }).accepted).toBe(true) - expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 }) - }) -}) - describe('subagent domain schemas', () => { it('carries optional request-local browser-zone provenance on prompts', () => { expect(subagentPromptRequestSchema.parse({ @@ -347,70 +159,6 @@ describe('host domain schemas', () => { }) }) -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], archivedSessionIds: ['s1'] }).items).toHaveLength(1) - expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow() - }) - - it('archiveSession request/value carry the id and the full updated set', () => { - expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow() - expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds) - .toEqual(['s1', 's2']) - expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).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') - }) - - it('create requires a path', () => { - expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') - expect(() => workspaceCreateRequestSchema.parse({})).toThrow() - // The retired create-by-name spelling stays a clean schema rejection. - expect(() => workspaceCreateRequestSchema.parse({ name: 'n' })).toThrow() - 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('insertBefore accepts an anchored or anchorless Workspace move and returns the complete order', () => { - expect(workspaceInsertBeforeRequestSchema.parse({ - workspaceId: 'w1', beforeWorkspaceId: 'w2', - }).beforeWorkspaceId).toBe('w2') - expect(workspaceInsertBeforeRequestSchema.parse({ workspaceId: 'w1' }).beforeWorkspaceId) - .toBeUndefined() - expect(() => workspaceInsertBeforeRequestSchema.parse({ beforeWorkspaceId: 'w2' })).toThrow() - expect(workspaceInsertBeforeValueSchema.parse({ workspaceIds: ['w2', 'w1'] }).workspaceIds) - .toEqual(['w2', 'w1']) - }) -}) - describe('skills domain schemas', () => { it('validates the list request/value pair', () => { expect(skillListRequestSchema.parse({ sessionId: 's1' })).toEqual({ sessionId: 's1' }) @@ -439,115 +187,6 @@ describe('goals domain schemas', () => { }) }) -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/queue', sessionId: 's', items: [ - { - id: 'm1', - placement: 'queued', - message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, - }, - ] }, - { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 }, - { type: 'session/jobs', sessionId: 's', jobs: [] }, - { type: 'session/jobs', sessionId: 's', jobs: [ - { id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5 }, - { id: 'pty-send-2', kind: 'pty-send', label: 'send keys', status: 'failed', detail: 'exit code: 3', startedAt: 5, finishedAt: 9 }, - ] }, - { 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 }, - // A producer kind stays an open string, but the closed status set and - // the identity/label bounds are the carrier's own wire contract. - { type: 'session/jobs', sessionId: 's', jobs: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] }, - { type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] }, - { type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] }, - { type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] }, - { type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] }, - { type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 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('carries a question presentation intent through, and rejects an unknown one', () => { - const intent = { kind: 'plan-review', approve: 'Approve' } - expect(askUserQuestionItemSchema.parse({ - id: 'plan-review', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }], intent, - }).intent).toEqual(intent) - // An unrecognised tag is a rejected frame, not a silently generic render. - for (const invalid of [{ kind: 'plan-review' }, { kind: 'poll', approve: 'Approve' }, { approve: 'Approve' }]) { - expect(() => askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?', intent: invalid })).toThrow() - } - }) - - it('accepts every queue placement and rejects unknown placements', () => { - const item = (placement: string) => ({ type: 'session/queue', sessionId: 's', items: [{ - id: 'm', placement, - message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } }, - }] }) - for (const placement of ['queued', 'steering', 'context']) { - expect(() => muxFrameSchema.parse(item(placement))).not.toThrow() - } - expect(() => muxFrameSchema.parse(item('bogus'))).toThrow() - }) - - it('rejects a queue snapshot with malformed items', () => { - expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', role: 'user', content: [], source: { kind: 'user' } }] })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'm', role: 'assistant', content: [], source: { kind: 'user' } }] })).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/remote-event', event: 'commands/change', args: [] }, - { type: 'host/remote-event', event: 'settings/document-updated', args: ['ns', 3] }, - { type: 'host/remote-event', event: 'agent-preset/selected', args: ['s', 'minimal'] }, - { type: 'host/remote-event', event: 'llm/adapters-updated', args: [] }, - { 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') - }) -}) - describe('agent-preset schemas', () => { it('accepts a roster row and rejects an unknown trust', () => { expect(agentPresetEntrySchema.parse({ id: 'standard', trust: 'system', isDefault: true })) diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index 5b5fcaa216..aa4137e031 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -10,7 +10,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { unzipSync, strFromU8 } from 'fflate' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' @@ -77,7 +76,6 @@ async function buildApi( } = {}, ) { const ctx = new Context() - await ctx.plugin(UserQuestionService) const query = services.query ?? true const persistence = services.persistence ?? true if (query) { @@ -129,30 +127,17 @@ describe('session export compression config', () => { it('defaults to level 6 and rejects values outside the integer 0-9 range', () => { expect(ApiProxyService.Config({})).toEqual({ sessionExportCompressionLevel: 6, - coldBlankProbeMaxBytes: 1024, }) expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 })) - .toEqual({ sessionExportCompressionLevel: 0, coldBlankProbeMaxBytes: 1024 }) + .toEqual({ sessionExportCompressionLevel: 0 }) expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 })) - .toEqual({ sessionExportCompressionLevel: 9, coldBlankProbeMaxBytes: 1024 }) + .toEqual({ sessionExportCompressionLevel: 9 }) for (const value of [-1, 10, 1.5]) { expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow() } }) }) -describe('cold blank probe config', () => { - it('accepts a per-Session byte bound including zero and rejects invalid bounds', () => { - expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 0 })) - .toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 0 }) - expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 2048 })) - .toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 2048 }) - for (const value of [-1, 1.5]) { - expect(() => ApiProxyService.Config({ coldBlankProbeMaxBytes: value })).toThrow() - } - }) -}) - describe('session.export download endpoint', () => { it('streams a ZIP with the root artifact verbatim under its original filename', async () => { const api = await buildApi({ 'session-root': artifact('session-root') }) diff --git a/packages/test-support/client-runtime/src/remote.ts b/packages/test-support/client-runtime/src/remote.ts index fab7f9b1fd..d90baf7e0f 100644 --- a/packages/test-support/client-runtime/src/remote.ts +++ b/packages/test-support/client-runtime/src/remote.ts @@ -1,16 +1,12 @@ -/** Test-owned Remote face: `$on` subscriptions driven by the internal forwarded-event plumbing. */ +/** Test-owned Remote face: `$on` subscriptions with an explicit test event driver. */ import type { Context } from '@deepseek-ai/cordis' /** * Remote service test double for the forwarded-event path. Feature specs need * `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded - * host events to reach those subscribers, but not the generated namespaces or - * the wire — so this double implements subscription and dispatch only. - * - * Dispatch is driven the same way production drives it: `client/runtime` owns the - * host frame sink and hands each decoded `host/remote-event` frame to - * `$dispatch`. A spec therefore exercises its refresh chains by calling - * `$dispatch(name, args)` on this double. + * Host events to reach those subscribers, but not the generated namespaces or + * the wire — so this double implements subscription plus an explicit `emit` + * driver available only on the concrete test object. * * `$mount` rejects: a spec that reaches a generated namespace through this * double has outgrown it and needs the real Client Remote service. @@ -37,7 +33,7 @@ export class TestRemote { * @param event - forwarded host event name. * @param args - the Host argument list, verbatim. */ - $dispatch(event: string, args: readonly unknown[]): void { + emit(event: string, args: readonly unknown[]): void { const listeners = this.subscriptions.get(event) if (listeners === undefined) return for (const listener of [...listeners]) listener(...args as never[]) diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index c014d6f002..44b3d35cf6 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -1,16 +1,15 @@ /** Test-owned sessions face: the SlotRegistry host contract over declarative fixtures. */ import type { Context } from '@deepseek-ai/cordis' import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment' -import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' +import { + createScope, scopeOf, SessionProvideChannel, SESSION_SEARCH_RESULT_LIMIT, +} from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { AgentContext, ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore, SubagentAddress, } from '@deepseek-ai/dsh-client-runtime/client' -// The double reports the wire schema's own search bound, like the production -// service — a transport-varying limit would be a fiction no client can see. -import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import { conversationSnapshot } from './fixtures.ts' import type { SessionFixture, Stabilizer } from './fixtures.ts' diff --git a/packages/test-support/client-runtime/tests/remote.client.spec.ts b/packages/test-support/client-runtime/tests/remote.client.spec.ts index 90b1bde56f..df376a83eb 100644 --- a/packages/test-support/client-runtime/tests/remote.client.spec.ts +++ b/packages/test-support/client-runtime/tests/remote.client.spec.ts @@ -16,21 +16,21 @@ describe('TestRemote', () => { seen.push(ns) }) - ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 1]) + remote.emit('settings/document-updated', ['ui-theme', 1]) expect(seen).toEqual(['ui-theme']) off() - ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 2]) + remote.emit('settings/document-updated', ['ui-theme', 2]) expect(seen).toEqual(['ui-theme']) await ctx.fiber.dispose() }) it('drops a forwarded event nobody subscribed to', async () => { const ctx = new Context() - new TestRemote(ctx) + const remote = new TestRemote(ctx) // No subscriber for this name: the emit must be inert rather than throwing, // because the wire carries whatever the Host allowlist selected. - expect(() => { ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY']) }).not.toThrow() + expect(() => { remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY']) }).not.toThrow() await ctx.fiber.dispose() })