refactor(client): replace legacy Host event carriers

This commit is contained in:
imccyu committed 2026-08-23 16:16:02 +08:00
1 parent ae25df3ac6
commit dcddaa1a6e
114 files changed
+5541 -8744

No files matched your search

+5 -4
View File
@@ -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)
+3 -2
View File
@@ -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<string | undefined> {
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 {
+5 -4
View File
@@ -482,12 +482,13 @@ describe('web e2e: long Chat scroll contract', () => {
let releaseGate: (() => void) | undefined
const gate = new Promise<void>((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) {
+11 -20
View File
@@ -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<string> => {
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<unknown> => {
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.
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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')
+7 -5
View File
@@ -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)
+33 -19
View File
@@ -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<string> {
})
}
async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<T> {
const response = await fetch(`${baseUrl}/api/${method}`, {
async function remoteRpc<T>(baseUrl: string, endpoint: string, args: object): Promise<T> {
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<HistoryPage> {
return rpc<HistoryPage>(baseUrl, 'session.history', { sessionId, maxMessages: 10 })
return remoteRpc<HistoryPage>(baseUrl, 'session/page', {
request: { address: { kind: 'session', sessionId }, maxMessages: 10 },
})
}
async function waitForProviderTitle(baseUrl: string, sessionId: string): Promise<string> {
@@ -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<never>((_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<never>((_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)
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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')
+4 -4
View File
@@ -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<void>((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 }
+2 -2
View File
@@ -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
+19 -2
View File
@@ -22,7 +22,7 @@ const WAKING = 'And add one concrete example.'
type RpcResult<T> = { 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<T>(baseUrl: string, method: string, payload: unknown): Promise<RpcResult<T>> {
const response = await fetch(`${baseUrl}/api/${method}`, {
method: 'POST',
@@ -38,6 +38,23 @@ async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promis
return (await response.json() as { result: RpcResult<T> }).result
}
/** POST one generated Session Remote unary through the API Gateway carrier. */
async function sessionRemote<T>(baseUrl: string, method: string, request: unknown): Promise<RpcResult<T>> {
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<T> }).result
}
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
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}`)
@@ -218,12 +218,13 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
let finishHeldRequest: () => void = () => {}
const gate = new Promise<void>((resolve) => { releaseHistory = resolve })
const heldRequestFinished = new Promise<void>((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)
})
+30 -10
View File
@@ -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<T> =
| { 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<void>> {
for (const contribution of [
commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote,
pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
sessionRemote, workspaceRemote,
]) {
disposers.push(await ctx.remote.$mount(contribution))
}
+145 -25
View File
@@ -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<TypertRemoteEventDispatch> {
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<void>((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<unknown> {
const settled = Promise.withResolvers<unknown>()
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[]
}
+20 -18
View File
@@ -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[]
+1 -1
View File
@@ -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<ApiRemoteForwardedEvent, true> {}
@@ -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<void>
}
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<unknown>,
): Promise<unknown> {
const waterfall = ctx.waterfall.bind(ctx) as unknown as (
receiver: object,
name: string,
...values: readonly unknown[]
) => Promise<unknown>
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()
})
})
+1 -8
View File
@@ -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/<anything>`). */
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`
@@ -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
+6 -9
View File
@@ -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'
@@ -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<ConnectionConfig> = {
backoffBaseMs: 500,
backoffFactor: 2,
backoffMaxMs: 10_000,
streamOpenTimeoutMs: 3_000,
generationReadyTimeoutMs: 3_000,
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
@@ -39,12 +36,9 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
* '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<MuxFrame>) => void
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => 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<void>
/**
* 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<void>((resolve) => { muxOpened = resolve }),
new Promise<void>((resolve) => { hostOpened = resolve }),
])
let sourceReady = false
let resolveReady!: () => void
let rejectReady!: (error: Error) => void
let rejectSourceLost!: (error: Error) => void
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve
rejectReady = reject
})
const sourceLost = new Promise<never>((_resolve, reject) => {
rejectSourceLost = reject
})
const reportReady = (): void => {
sourceReady = true
resolveReady()
}
const failed = new Promise<void>((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<F extends { type: string }>(
stream: AsyncIterable<RpcRequest<F>>,
sink: ((envelope: RpcRequest<F>) => void) | undefined,
onEnd: () => void,
): Promise<void> {
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<void>, timeoutMs: number, signal: AbortSignal): Promise<void> {
return new Promise<void>((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)
},
)
})
}
File diff suppressed because it is too large. Load diff
+62 -23
View File
@@ -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)
},
}
+56 -4
View File
@@ -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<Response>
/** Worker-local opener for decoded Gateway Remote streams. */
export type RpcStreamOpen = (
endpoint: string,
payload: unknown,
signal: AbortSignal,
) => AsyncIterable<unknown>
/**
* 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<unknown>
} {
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<PropertyKey, unknown> {
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
@@ -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<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' }
type Parser<F> = { 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<Response> {
return globalThis.fetch(input, init)
}
protected override openMux(
_payload: Parameters<ApiProxy['events']['mux']>[0]['payload'],
signal: AbortSignal,
onOpen?: () => void,
): AsyncIterable<RpcRequest<MuxFrame>> {
return this.readWebSocket(MUX_EVENTS_PATH, signal, muxFrameSchema, onOpen)
}
protected override openHost(
_payload: Parameters<ApiProxy['events']['host']>[0]['payload'],
signal: AbortSignal,
onOpen?: () => void,
): AsyncIterable<RpcRequest<HostFrame>> {
return this.readWebSocket(HOST_EVENTS_PATH, signal, hostFrameSchema, onOpen)
}
private async *readWebSocket<F extends MuxFrame | HostFrame>(
path: string,
signal: AbortSignal,
frameSchema: Parser<F>,
onOpen?: () => void,
): AsyncGenerator<RpcRequest<F>> {
const url = new URL(path, this.resolveBase())
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
const socket = new WebSocket(url)
const inbox: SocketItem<F>[] = []
let wake: (() => void) | undefined
const enqueue = (item: SocketItem<F>): 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<F>
if (item.kind === 'end') return
yield item.envelope
}
await new Promise<void>((resolve) => { wake = resolve })
}
} finally {
signal.removeEventListener('abort', handleAbort)
socket.removeEventListener('open', handleOpen)
socket.removeEventListener('message', handleMessage)
socket.removeEventListener('close', handleClose)
handleAbort()
}
}
}
@@ -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)) {
+7 -32
View File
@@ -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) })
}
+16 -3
View File
@@ -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<unknown>
}
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<unknown>): Response {
const body: ConnectionServerResponse = { type: 'server-response', rpcId, result }
return Response.json(body)
}
+44 -4
View File
@@ -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<T> =
| { 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<Record<string, string | readonly string[] | undefined>>
}
/** 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<RpcResult<unknown>>
) => Promise<ConnectionRpcResult<unknown>>
/** 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<RpcResult<unknown>>
): Promise<ConnectionRpcResult<unknown>>
/**
* 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<unknown>
}
@@ -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<Frame>): ServerRequest {
return {
type: 'server-request',
rpcId: frame.rpcId,
method: frame.payload.type,
payload: frame.payload,
}
}
function send(socket: WebSocket, frame: RpcRequest<Frame>): Promise<void> {
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<Frame> {
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<Promise<void>>()
/** @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<void> {
for (const socket of this.server.clients) socket.terminate()
await new Promise<void>((resolve, reject) => {
this.server.close((error) => {
if (error === undefined) resolve()
else reject(error)
})
})
await Promise.all(this.pumps)
}
private upgrade<F extends Frame>(
req: IncomingMessage,
socket: Duplex,
head: Buffer,
open: (signal: AbortSignal) => AsyncIterable<RpcRequest<F>>,
): 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<F extends Frame>(
socket: WebSocket,
frames: AsyncIterable<RpcRequest<F>>,
abort: AbortController,
): Promise<void> {
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'))
}
@@ -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<void>((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<ConnectionHandle> {
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<boolean | undefined> = []
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<ConnectionHandle['start']> } = {}
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<boolean | undefined> = []
const reconnectSnapshots: Array<boolean | undefined> = []
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<string, unknown>).__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<NonNullable<ClientTransportHooks['openStream']>>(
(endpoint, payload, signal) => (async function *(): AsyncGenerator {
signal.throwIfAborted()
yield { endpoint, payload }
})(),
)
;(globalThis as Win).__DSH_TRANSPORT__ = {
createApiClient: () => new FixtureApiClient(),
fetch: vi.fn<ClientTransportHooks['fetch']>(),
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
}
@@ -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<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
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<void>((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()
@@ -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<T> {
@@ -31,10 +29,10 @@ export function ok<T>(value: T): RpcResponse<T> {
}
type StreamItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' } | { kind: 'fail'; error: unknown }
type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown }
interface StreamConn<F> {
feed(item: StreamItem<F>): 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<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelSelection: ModelSelection }>> =
() => Promise.resolve(ok({
events: [],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: { provider: 'deepseek-official', model: 'deepseek-chat' },
routable: true,
groups: [],
failures: [],
}))
onSelectModel: (payload: ModelSelection & { sessionId: SessionId })
=> Promise<RpcResponse<{ selected: ModelSelection }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{
version: string
cwd: string
@@ -97,42 +67,13 @@ export class FakeApiClient implements IApiClient {
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
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<RpcResponse<{ skills: SkillEntry[] }>>
@@ -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<F>(registry: StreamConn<F>[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator<RpcRequest<F>> {
const inbox: StreamItem<F>[] = []
private async openGeneration(signal: AbortSignal, onOpen: () => void): Promise<void> {
const inbox: StreamItem[] = []
let wake: (() => void) | null = null
const conn: StreamConn<F> = {
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<F>
const item = inbox.shift() as StreamItem
if (item.kind === 'end') return
if (item.kind === 'fail') throw item.error
yield item.envelope
}
await new Promise<void>((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)
}
}
}
@@ -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<string, unknown> } } => (f as { type: string }).type === 'session/event')
.filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (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<string, unknown> } } => (f as { type: string }).type === 'session/event')
.filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'event')
.map(f => f.event)
expect(events).toMatchObject([
{ type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } },
File diff suppressed because it is too large. Load diff
@@ -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<void>
}> {
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[] = []
@@ -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<RpcRequest<MuxFrame>>
type HostSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<HostFrame>>
const running: (() => Promise<void>)[] = []
afterEach(async () => {
await Promise.all(running.splice(0).map(close => close()))
})
function untilAbort(signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve()
return new Promise((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
async function * idle<F>(signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
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<void>
}> {
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<void>(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<void>(resolve => server.close(() => { resolve() }))
},
}
}
function read(socket: WebSocket): Promise<ServerRequest> {
return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest)
}
async function acceptedSocket(downlinks: WebSocketDownlinks): Promise<WebSocket> {
const server = (downlinks as unknown as { server: { clients: Set<WebSocket> } }).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<void>((resolve) => { release = resolve })
let finish!: () => void
const finished = new Promise<void>((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<void>((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<void>((resolve) => { cleanupStarted = resolve })
let releaseCleanup!: () => void
const cleanupGate = new Promise<void>((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
}
})
})
@@ -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)
})
@@ -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<Context, 'remote'> & {
readonly remote: TypertClientRemote & TypertRemoteScopeApi<'agent'>
readonly remote: ClientRemote & TypertRemoteScopeApi<'agent'>
}
/** Context tag written by {@link createScope}. */
@@ -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. */
@@ -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<RpcResult<{ accepted: true }>>
): Promise<ClientResult<{ accepted: true }>>
/**
* 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<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
/**
* 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<RpcResult<{ accepted: true }>>
updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>>
/**
* 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<RpcResult<{ accepted: true }>>
cancel(): Promise<ClientResult<{ accepted: true }>>
/**
* 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<RpcResult<{ title: string; seq: number }>>
rename(title: string): Promise<ClientResult<{ title: string; seq: number }>>
/**
* Extend the history window backwards (older messages pagination).
* @returns completion; failures land in snapshot.openState/loadingOlder.
@@ -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<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
/**
* 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.
+56 -38
View File
@@ -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')
}
@@ -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.
*/
@@ -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<Partial<SessionProjectionMap>>
/** 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
@@ -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<Record<SessionId, SubagentCatalogSnapshot>>
/** Background jobs per session; an absent key is an empty set. */
jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
@@ -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<MuxFrame>): 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<MuxFrame, { type: 'question/requested' }>['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<SessionId, Session>()
/** 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<SessionId, RpcRequest<MuxFrame>[]>()
/** Latest transient queues, retained independently of Session object materialization. */
private readonly queues = new Map<SessionId, readonly SessionQueuedItem[]>()
/** Answerable requests retained by stable identity for lazy Session materialization. */
private readonly interactions = new Map<
SessionId,
Map<SessionInteractionId, SessionApprovalRequest | SessionQuestionRequest>
>()
/** 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<SessionId, Map<string, PendingInteractionStatus>>()
/**
* 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<void> | 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<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
/**
* 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<SessionId, readonly JobView[]>()
@@ -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<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
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<RpcResult<{ sessionId: SessionId }>> {
opts: {
workspaceId?: WorkspaceId
cwd?: string
sessionId?: SessionId
} = {},
): Promise<ClientResult<{ sessionId: SessionId }>> {
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<RpcResult<{ sessionId: SessionId }>> {
): Promise<ClientResult<{ sessionId: SessionId }>> {
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<MuxFrame>): 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<HostFrame>): 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<T>(
result: import('@deepseek-ai/dsh-typert-protocol').RemoteResult<T>,
): ClientResult<T> {
return result.ok ? result : { ok: false, error: result.error as SessionError }
}
@@ -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<Extract<MuxFrame, { type: 'approval/requested' }>, 'type' | 'sessionId'>
question: Omit<Extract<MuxFrame, { type: 'question/requested' }>, 'type' | 'sessionId'>
approval: Omit<SessionApprovalRequest, 'interactionId' | 'sessionId'>
question: Omit<SessionQuestionRequest, 'interactionId' | 'sessionId'>
}
/** Pending-interaction discriminant (the keys of PendingPayloads). */
@@ -26,53 +31,60 @@ const KEY_PREFIX: Record<PendingKind, string> = { 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<K extends PendingKind = PendingKind> {
/** Interaction kind (union discriminant). */
readonly kind: K
/** Opaque render identity, `<prefix>:<rpcId>` — 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<RpcReceipt>
readonly #interactionId: SessionInteractionId
readonly #respond: (request: SessionRespondRequest) => Promise<RemoteResult<SessionRespondReceipt>>
/**
* 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<RpcReceipt>,
kind: K, interactionId: SessionInteractionId, sessionId: SessionId, payload: PendingPayloads[K],
respond: (request: SessionRespondRequest) => Promise<RemoteResult<SessionRespondReceipt>>,
) {
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<RpcReceipt> {
respond(result: SessionInteractionResult): Promise<SessionRespondReceipt> {
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<SessionRespondReceipt> {
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. */
@@ -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<SessionProjectionMap>
values: Readonly<Record<string, unknown>>
}
/** 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) {
@@ -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<MuxFrame, { type: 'session/queue' }>['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),
}
})
}
/**
@@ -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<Context['remote'], 'commands'>
/** The generated Remote namespaces and Gateway stream factory a Session cluster uses. */
export type SessionRemotes = Pick<Context['remote'], '$stream' | 'commands' | 'session'>
@@ -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<Record<SessionId, SubagentCatalogSnapshot>>
/**
* 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<Record<SessionId, readonly JobView[]>>
/** 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<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
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<SessionManager['handleMuxEnvelope']>[0]): void {
this.manager.handleMuxEnvelope(envelope)
handleControlFrame(frame: Parameters<SessionManager['handleControlFrame']>[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<SessionManager['handleHostEnvelope']>[0]): void {
this.manager.handleHostEnvelope(envelope)
handleSessionAdded(summary: Parameters<SessionManager['handleSessionAdded']>[0]): void {
this.manager.handleSessionAdded(summary)
}
/**
* Apply one remotely forwarded Session removal.
* @param sessionId - removed Session identity.
*/
handleSessionRemoved(sessionId: Parameters<SessionManager['handleSessionRemoved']>[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<SessionManager['handleSessionStatus']>): void {
this.manager.handleSessionStatus(...args)
}
/**
* Apply one remotely forwarded list-activity change.
* @param args - Session identity and durable activity timestamp.
*/
handleSessionActivity(...args: Parameters<SessionManager['handleSessionActivity']>): void {
this.manager.handleSessionActivity(...args)
}
/**
* Apply one remotely forwarded Agent failure.
* @param args - Session identity and caller-visible failure description.
*/
handleSessionError(...args: Parameters<SessionManager['handleSessionError']>): 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
@@ -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<void> | 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<RpcResult<{ accepted: true }>> {
): Promise<ClientResult<{ accepted: true }>> {
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<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
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<RpcResult<{ accepted: true }>> {
async updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>> {
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<RpcResult<{ accepted: true }>> {
async cancel(): Promise<ClientResult<{ accepted: true }>> {
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<RpcResult<{ title: string; seq: number }>> {
async rename(title: string): Promise<ClientResult<{ title: string; seq: number }>> {
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<void> {
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<void> {
// 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<SessionControlFrame, { type: 'baseline' | 'jobs' | 'projection' }>): 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<void> {
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<never>(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<void> {
/* 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<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: ProjectionsBaseline
}>> {
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<never>(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<T>(result: RemoteResult<T>): ClientResult<T> {
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. */
@@ -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<typeof vi.fn<ConnectionHandle['start']>>
dispatchRemote(event: string, args: readonly unknown[]): void
}
async function mount(): Promise<Bench> {
async function mount(configure?: (api: FakeApiClient) => void): Promise<Bench> {
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<string, Set<(...args: never[]) => void>>()
const dispatchRemote = (event: string, args: readonly unknown[]): void => {
for (const listener of listeners.get(event) ?? []) listener(...args as never[])
}
const start = vi.fn<ConnectionHandle['start']>(() => ({ stop: () => {} }))
const bench: Bench = { ctx, api, start, dispatchRemote }
const handle: ConnectionHandle = {
api,
isLoopback: true,
@@ -38,14 +42,23 @@ async function mount(): Promise<Bench> {
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<void> {
}
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()
})
})
@@ -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)
+303 -126
View File
@@ -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> = {}): WorkspaceView {
return {
@@ -22,6 +49,16 @@ function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): 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<T> {
promise: Promise<T>
resolve(value: T): void
@@ -49,10 +86,28 @@ export function err<T>(error: RpcError): RpcResponse<T> {
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } }
}
type StreamItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' } | { kind: 'fail'; error: unknown }
/** Successful generated Remote result for programmable domain fakes. */
export function remoteOk<T>(value: T): RemoteResult<T> {
return { ok: true, value }
}
interface StreamConn<F> {
feed(item: StreamItem<F>): void
/** Workspace business failure returned by a generated Remote fake. */
export function workspaceErr(error: WorkspaceError): RemoteResult<never> {
return { ok: false, error }
}
type ValueStreamItem<F> =
| { kind: 'frame'; value: F; delivered?: () => void }
| { kind: 'end' }
| { kind: 'fail'; error: unknown }
interface ValueStreamConn<F> {
feed(item: ValueStreamItem<F>): void
}
interface OpenValueStream<F> {
readonly values: AsyncGenerator<F>
dispose(): void
}
/**
@@ -60,13 +115,10 @@ interface StreamConn<F> {
* 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<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
=> Promise<RpcResponse<SessionPage>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
@@ -131,37 +183,27 @@ export class FakeApiClient implements IApiClient {
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
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<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
private readonly openingPages = new Map<string, Promise<RemoteResult<SessionPage>>>()
/** Optional Host opening cursor override for stale-page and reconnect tests. */
followCursor: number | undefined
controlBaseline: SessionControlBaseline = {
queues: {},
jobs: {},
approvals: [],
questions: [],
projections: {},
}
workspaceBaseline: Extract<WorkspaceFollowFrame, { type: 'baseline' }>['value'] = {
items: [],
archivedSessionIds: [],
}
lastSearchSignal: AbortSignal | undefined
onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
onSubagentHistory: (payload: unknown) => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>>
= () => Promise.resolve(ok({ events: [], hasMore: false }))
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
= () => 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<RpcResponse<{ items: never[]; archivedSessionIds?: never[] }>> =
() => Promise.resolve(ok({ items: [] }))
onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
onWorkspaceCreate: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView; created: boolean }>> =
() => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true }))
onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceRename: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
() => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
() => Promise.resolve(ok({ deleted: true }))
onWorkspaceDelete: (payload: unknown) => Promise<RemoteResult<{ deleted: true }>> =
() => Promise.resolve(remoteOk({ deleted: true }))
onWorkspaceInsertBefore: (payload: unknown) => Promise<RpcResponse<{ workspaceIds: WorkspaceId[] }>> =
() => Promise.resolve(ok({ workspaceIds: [] }))
onWorkspaceInsertBefore: (payload: unknown) => Promise<RemoteResult<{ workspaceIds: WorkspaceId[] }>> =
() => Promise.resolve(remoteOk({ workspaceIds: [] }))
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
() => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceArchiveSession: (payload: unknown) => Promise<RpcResponse<{ archivedSessionIds: SessionId[] }>> =
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<IApiClient['workspace']['list']>),
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<RemoteResult<{ archivedSessionIds: SessionId[] }>> =
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<RemoteResult<SessionRespondReceipt>> =
() => 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: <Item>(options: RemoteStreamOptions<Item>) => (
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<SessionFollowFrame, { type: 'event' }>,
): Promise<void> {
await Promise.all([...(this.followConns.get(sessionId) ?? [])].map(conn => new Promise<void>((resolve) => {
conn.feed({ kind: 'frame', value: frame, delivered: resolve })
})))
}
onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
respond(message: ClientResponse): Promise<RpcReceipt> {
return this.record('respond', message, this.onRespond(message))
/** Push one Host-wide control update. */
pushControl(frame: Exclude<SessionControlFrame, { type: 'baseline' }>): 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<WorkspaceFollowFrame, { type: 'baseline' }>): 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<F>(registry: StreamConn<F>[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator<RpcRequest<F>> {
const inbox: StreamItem<F>[] = []
private async remoteResult<T>(
method: string,
payload: unknown,
response: Promise<RpcResponse<T>>,
): Promise<RemoteResult<T>> {
return (await this.record(method, payload, response)).result
}
private page(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
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<RemoteResult<SessionPage>> {
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<SessionFollowFrame> {
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<SessionControlFrame> {
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<WorkspaceFollowFrame> {
const stream = this.openValueStream(this.workspaceConns, signal)
try {
yield { type: 'baseline', value: this.workspaceBaseline }
yield* stream.values
} finally {
stream.dispose()
}
}
private openValueStream<F>(
registry: ValueStreamConn<F>[],
signal: AbortSignal,
): OpenValueStream<F> {
const inbox: ValueStreamItem<F>[] = []
let wake: (() => void) | null = null
const conn: StreamConn<F> = {
let inFlightDelivered: (() => void) | undefined
let disposed = false
const conn: ValueStreamConn<F> = {
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<F>
if (item.kind === 'end') return
if (item.kind === 'fail') throw item.error
yield item.envelope
}
await new Promise<void>((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<F> {
try {
while (!signal.aborted && !disposed) {
while (inbox.length > 0) {
const item = inbox.shift() as ValueStreamItem<F>
if (item.kind === 'end') return
if (item.kind === 'fail') throw item.error
inFlightDelivered = item.delivered
yield item.value
inFlightDelivered?.()
inFlightDelivered = undefined
}
await new Promise<void>((resolve) => {
wake = resolve
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
wake = null
}
} finally {
dispose()
}
})()
return { values, dispose }
}
}
File diff suppressed because it is too large. Load diff
@@ -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()
})
})
@@ -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<SessionControlFrame, { type: 'queue' }> {
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'])
@@ -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<void> {
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<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
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)
})
})
@@ -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<void> {
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 })
@@ -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<Bench> {
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)
})
})
@@ -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')
})
@@ -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<string, Array<(...args: never[]) => 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<SessionId, number>()
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()
@@ -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.
*/
@@ -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[]
}
@@ -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()),
],
})
@@ -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<NonNullable<ConversationEventInput['view']>, { 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 }] }
}
@@ -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()
@@ -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)
@@ -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()
@@ -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)
@@ -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)
@@ -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) })
@@ -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]
@@ -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<typeof PendingWait<'question'>>[4]
const seatOver = (dict: Record<string, string>, common: Record<string, string>): 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<RpcReceipt>({ 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<RpcReceipt>({ accepted: false, reason: 'not-pending' })),
vi.fn(() => Promise.resolve({
ok: true as const,
value: { accepted: false as const, reason: 'not-pending' as const },
})),
)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
@@ -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<typeof PendingWait<'question'>>[4]
const seatOver = (dict: Record<string, string>, common: Record<string, string>): 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<RpcReceipt>({ 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(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
@@ -288,9 +295,9 @@ describe('QuestionComposer', () => {
})
it('renders chrome copy through the English dictionary', () => {
const respond = vi.fn(() => Promise.resolve<RpcReceipt>({ 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(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
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: {} },
+1 -8
View File
@@ -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<void> {
// 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) => {
@@ -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<void> }
}
const moduleFace = modulesClient as unknown as Record<string, unknown>
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<string, ClientBundleRegistration>([
['/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')
@@ -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<Bench> {
// 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<string, ((...args: never[]) => 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<Bench> {
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<Bench> {
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,
File diff suppressed because it is too large. Load diff
@@ -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. */
@@ -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`
+2 -3
View File
@@ -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.
*/
@@ -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<Wire<AskUserQuestionItem>>
/** 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<MuxFrame>
/** 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<HostFrame>
-155
View File
@@ -1,155 +0,0 @@
/**
* events domain contract: signatures and frame unions for the two logical
* streams. Four-quadrant: streams yield the narrow form `RpcRequest<Frame>` (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<SessionId, number> }>, signal: AbortSignal): AsyncIterable<RpcRequest<MuxFrame>>
/**
* 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<RpcRequest<HostFrame>>
}
/**
* 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 }
+4 -5
View File
@@ -2,11 +2,10 @@
* goals domain contract. Method signatures are the source of truth:
* unary methods take the RpcRequest<P> 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'
+6 -36
View File
@@ -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<RpcReceipt>
}
// ---- 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'
+42 -1
View File
@@ -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<Wire<ModelReasoningEffort>>
/** Exact-model reasoning metadata. */
const modelReasoningSchema = z.object({
efforts: z.array(modelReasoningEffortSchema).min(1),
defaultEffort: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<ModelReasoning>>
/** 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<Wire<ModelCatalogModel>>
/** 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<Wire<ModelProviderGroup>>
/** 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<Wire<ModelCatalogFailure>>
/** ConfigurableProviderView row of llm.providers. */
export const configurableProviderViewSchema = z.object({
+5 -3
View File
@@ -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 {
+3 -26
View File
@@ -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']
+6 -44
View File
@@ -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<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
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<T>(value: z.ZodType<T>): z.ZodUnion<readonly [z.
])
}
// ---- The four wire full-form schemas (payload/result.value slots stay wide — business layer does the second parse) ----
// ---- Wire envelope schemas (payload/result.value stay wide for the second business parse) ----
// The wide value slot is optional: a void business result serializes with no
// `value` field at all. Each endpoint's own second parse still requires its
// declared value, so absence never passes for a method that returns data.
@@ -109,31 +94,8 @@ export const serverResponseSchema = z.object({
result: rpcResultSchema(z.unknown().optional()),
}) as unknown as z.ZodType<ServerResponse>
/** 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<ServerRequest>
/** 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<ClientResponse>
/** Wire full-form union (discriminated by type). */
export const rpcMessageSchema = z.discriminatedUnion('type', [
clientRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
serverResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
serverRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
clientResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
])
/** 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<Wire<RpcReceipt>>
+7 -54
View File
@@ -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<T> {
result: RpcResult<T>
}
// ---- 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/<method> body). */
export interface ClientRequest {
@@ -156,32 +136,5 @@ export interface ServerResponse {
result: RpcResult<unknown>
}
/**
* 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<unknown>
}
/** 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
@@ -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. */
+4 -4
View File
@@ -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. */
@@ -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<Wire<ResponseValue<'subagent.list'>>>
/** 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<Wire<RequestPayload<'subagent.history'>>>
/** subagent.history response value. */
export const subagentHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
projections: sessionProjectionsBlockSchema.optional(),
}) as unknown as z.ZodType<Wire<ResponseValue<'subagent.history'>>>
/** subagent.prompt request payload. */
export const subagentPromptRequestSchema = z.object({
parentSessionId: sessionIdSchema,
+1 -20
View File
@@ -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<RpcResponse<SubagentCatalog>>
/**
* 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<SubagentAddress & { beforeSeq?: number; maxMessages?: number }>,
signal?: AbortSignal,
): Promise<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: SessionProjectionsBlock
}>>
/**
* Delivers human content to a continuable child through the exact live
* parent's continuation owner. Success identifies the message accepted by
+8 -182
View File
@@ -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<RpcResponse<ResponseValue<'session.list'>>>
search(payload: RequestPayload<'session.search'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.search'>>>
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.attachment'>>>
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
}
subagents: {
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.interrupt'>>>
}
@@ -113,15 +68,6 @@ export interface IApiClient {
createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.createDirectory'>>>
openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.openPath'>>>
}
workspace: {
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertBefore'>>>
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.archiveSession'>>>
}
skills: {
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
}
@@ -133,10 +79,6 @@ export interface IApiClient {
openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>>
}
events: {
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
}
goals: {
create(payload: RequestPayload<'goal.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.create'>>>
edit(payload: RequestPayload<'goal.edit'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.edit'>>>
@@ -162,8 +104,6 @@ export interface IApiClient {
models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.models'>>>
discoverModels(payload: RequestPayload<'llm.discoverModels'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.discoverModels'>>>
}
/** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
}
/**
@@ -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<Wire<ResponseValue<K>>> } = {
'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<Wire<ResponseV
'host.listDirectory': hostListDirectoryValueSchema,
'host.createDirectory': hostCreateDirectoryValueSchema,
'host.openPath': hostOpenPathValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
'workspace.rename': workspaceRenameValueSchema,
'workspace.delete': workspaceDeleteValueSchema,
'workspace.insertBefore': workspaceInsertBeforeValueSchema,
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
'workspace.archiveSession': workspaceArchiveSessionValueSchema,
'skill.list': skillListValueSchema,
'agentPreset.list': agentPresetListValueSchema,
'agentPreset.select': agentPresetSelectValueSchema,
@@ -248,7 +168,7 @@ export abstract class AbstractApiClient implements IApiClient {
private flushScheduled = false
private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => 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<Response> {
@@ -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<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>> {
return this.readSse('/api/events.mux', signal, muxFrameSchema, onOpen)
}
/** Host stream opener; virtual. */
protected openHost(_payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>> {
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<F extends MuxFrame | HostFrame>(
path: string,
signal: AbortSignal,
frameSchema: z.ZodType<F>,
onOpen?: () => void,
): AsyncGenerator<RpcRequest<F>> {
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<RpcReceipt> {
this.onEnvelope(message)
const response = await this.postJson('/api/respond', message, signal)
return rpcReceiptSchema.parse(await response.json())
}
}
/**
+5 -106
View File
@@ -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<K extends keyof RpcMethodMap>(
}
}
/** SSE frame: complete the narrow RpcRequest<frame> into a ServerRequest full form (method = frame type). */
function fullFrame(narrow: RpcRequest<MuxFrame | HostFrame>): 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<RpcRequest<MuxFrame | HostFrame>>): Response {
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
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 })
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more