refactor(client): replace legacy Host event carriers
This commit is contained in:
1 parent
ae25df3ac6
commit
dcddaa1a6e
114 files changed
+5541
-8744
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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[]
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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>>
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
Reference in new issue
Block a user