mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #3083 from deepseek-harness/worktree-apire-b
refactor(apiproxy): subagent control to Remote
This commit is contained in:
@@ -23,7 +23,9 @@ import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import {
|
||||
connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft,
|
||||
} from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/agent-preset-selection', import.meta.url))
|
||||
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
|
||||
@@ -287,7 +289,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
// `minimal` mounts neither the compaction group nor plan mode nor local
|
||||
// skill discovery, so the catalog the composer warmed under the
|
||||
// deployment default must not survive the switch.
|
||||
await composer.fill('/')
|
||||
await writeComposerDraft(page, composer, '/')
|
||||
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
|
||||
.not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
|
||||
const onMinimal = await menuOptions(page)
|
||||
@@ -297,7 +299,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
// remains outside every preset.
|
||||
expect(onMinimal.some(option => option.startsWith('goal'))).toBe(false)
|
||||
expect(onMinimal.some(option => option.startsWith('model'))).toBe(true)
|
||||
await composer.fill('')
|
||||
await writeComposerDraft(page, composer, '')
|
||||
|
||||
// Switching back up reaches the host at all — the chip compares the pick
|
||||
// against its list row, so a row that never reprojected the first switch
|
||||
@@ -307,14 +309,14 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await page.getByRole('menuitem', { name: /^Standard mode/ }).first().click()
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
|
||||
|
||||
await composer.fill('/')
|
||||
await writeComposerDraft(page, composer, '/')
|
||||
await expect.poll(() => menuOptions(page), { timeout: 15_000 })
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
|
||||
const onStandard = await menuOptions(page)
|
||||
expect(onStandard.some(option => option.startsWith('compact'))).toBe(true)
|
||||
expect(onStandard.some(option => option.startsWith('goal'))).toBe(true)
|
||||
expect(onStandard.some(option => option.startsWith('plan'))).toBe(true)
|
||||
await composer.fill('')
|
||||
await writeComposerDraft(page, composer, '')
|
||||
}, 90_000)
|
||||
|
||||
it('labels a resumed session with the preset it was created under', async () => {
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
|
||||
await input.fill(PROMPTS[index] as string)
|
||||
await input.press('Enter')
|
||||
sessionId = await settled
|
||||
await expect.poll(() => input.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
await input.fill('/permission read-only')
|
||||
|
||||
@@ -267,7 +267,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
|
||||
it('keeps known descendants reachable across a stale empty catalog response', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
|
||||
const pattern = '**/api/subagent.list'
|
||||
const pattern = '**/api/subagents/list'
|
||||
let firstClaimed = false
|
||||
let emptyDelivered = false
|
||||
let trailingRequested = false
|
||||
@@ -373,7 +373,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
|
||||
it('keeps a restored child neutral until its parent availability arrives', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-restore'))
|
||||
const pattern = '**/api/subagent.list'
|
||||
const pattern = '**/api/subagents/list'
|
||||
let requested = false
|
||||
let releaseCatalog = (): void => {}
|
||||
const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
|
||||
@@ -546,7 +546,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
await input.waitFor()
|
||||
const promptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.prompt')
|
||||
new URL(response.url()).pathname === '/api/subagents/prompt')
|
||||
await input.fill(POST_FORK_FOLLOWUP)
|
||||
await input.press('Enter')
|
||||
const promptReceipt = await (await promptResponse).json() as {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Web e2e scenario: the composer's independent Stop interrupts a running
|
||||
// continuable child. The child holds its model turn open through a replay
|
||||
// hang entry; the browser proves Send and Stop coexist, the parent-offline
|
||||
// disabled-Send-with-Stop composer, the subagent.interrupt
|
||||
// disabled-Send-with-Stop composer, the subagents/interruptByParent
|
||||
// (never session.cancel) transport, the parked follow-up, and the FIFO resume
|
||||
// on a waking send.
|
||||
//
|
||||
@@ -187,7 +187,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
|
||||
// parentAvailable: false while the child Activation stays live (the
|
||||
// interrupt RPC itself needs no live parent — covered host-side by
|
||||
// subagent-interrupt.e2e.ts).
|
||||
const pattern = '**/api/subagent.list'
|
||||
const pattern = '**/api/subagents/list'
|
||||
await page.route(pattern, async (route) => {
|
||||
const response = await route.fetch()
|
||||
const body = await response.json() as {
|
||||
@@ -228,7 +228,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
|
||||
)
|
||||
const aborted = waitForAbortedTurn(scaffold, childId)
|
||||
const interruptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.interrupt')
|
||||
new URL(response.url()).pathname === '/api/subagents/interruptByParent')
|
||||
await stop.click()
|
||||
expect(((await (await interruptResponse).json()) as {
|
||||
result: { ok: boolean; value?: { accepted: boolean } }
|
||||
@@ -252,7 +252,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('interrupts through subagent.interrupt, parks the follow-up, and resumes it FIFO', async () => {
|
||||
it('interrupts through subagents/interruptByParent, parks the follow-up, and resumes it FIFO', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow'))
|
||||
// Reselect the child with the truthful catalog: parent available again.
|
||||
await page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
@@ -265,7 +265,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
|
||||
|
||||
// Queue a follow-up through Send while independent Stop remains available.
|
||||
const promptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.prompt')
|
||||
new URL(response.url()).pathname === '/api/subagents/prompt')
|
||||
await input.fill(FOLLOWUP)
|
||||
await page.getByRole('button', { name: 'Send message' }).click()
|
||||
expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
|
||||
@@ -275,7 +275,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
|
||||
const stop = page.getByRole('button', { name: 'Stop generating' })
|
||||
expect(await stop.count()).toBe(1)
|
||||
const interruptResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/subagent.interrupt')
|
||||
new URL(response.url()).pathname === '/api/subagents/interruptByParent')
|
||||
await stop.click()
|
||||
expect(((await (await interruptResponse).json()) as {
|
||||
result: { ok: boolean; value?: { accepted: boolean } }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Web e2e scenario (browserless): the subagent.interrupt RPC against the real
|
||||
// Web e2e scenario (browserless): the subagents interrupt Remote against the real
|
||||
// composition. A live continuable child holds its model turn open through a
|
||||
// replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and
|
||||
// proves from the real session state that the turn aborted, the follow-up
|
||||
@@ -22,25 +22,12 @@ const WAKING = 'And add one concrete example.'
|
||||
|
||||
type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** POST one API Proxy unary RPC through the real HTTP carrier and unwrap its result. */
|
||||
async function rpc<T>(scaffold: WebScaffold, method: string, payload: unknown): Promise<RpcResult<T>> {
|
||||
const response = await scaffold.hostFetch(`/api/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: `interrupt-e2e-${method}-${randomUUID()}`,
|
||||
method,
|
||||
payload,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
|
||||
return (await response.json() as { result: RpcResult<T> }).result
|
||||
}
|
||||
|
||||
/** POST one generated Session Remote unary through the API Gateway carrier. */
|
||||
async function sessionRemote<T>(scaffold: WebScaffold, method: string, request: unknown): Promise<RpcResult<T>> {
|
||||
const endpoint = `session/${method}`
|
||||
/** POST one generated Remote unary through the API Gateway carrier. */
|
||||
async function remote<T>(
|
||||
scaffold: WebScaffold,
|
||||
endpoint: string,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
): Promise<RpcResult<T>> {
|
||||
const response = await scaffold.hostFetch(`/api/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -48,13 +35,18 @@ async function sessionRemote<T>(scaffold: WebScaffold, method: string, request:
|
||||
type: 'client-request',
|
||||
rpcId: `interrupt-e2e-${endpoint}-${randomUUID()}`,
|
||||
method: endpoint,
|
||||
payload: { args: { request } },
|
||||
payload: { args },
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`)
|
||||
return (await response.json() as { result: RpcResult<T> }).result
|
||||
}
|
||||
|
||||
/** POST one generated Session Remote unary through the API Gateway carrier. */
|
||||
function sessionRemote<T>(scaffold: WebScaffold, method: string, request: unknown): Promise<RpcResult<T>> {
|
||||
return remote<T>(scaffold, `session/${method}`, { request })
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -78,7 +70,7 @@ function textCompletion(text: string): object {
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => {
|
||||
describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over the real composition', () => {
|
||||
let scaffold: WebScaffold
|
||||
let sidecarRoot: string
|
||||
let readyFile: string
|
||||
@@ -138,18 +130,21 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co
|
||||
|
||||
it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => {
|
||||
// Queue the follow-up while the turn is still open, then interrupt.
|
||||
const queued = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: FOLLOWUP }],
|
||||
const queued = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', {
|
||||
request: {
|
||||
requestId: randomUUID(),
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: FOLLOWUP }],
|
||||
},
|
||||
})
|
||||
expect(queued).toMatchObject({ ok: true })
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const interrupted = await rpc<{ accepted: true }>(scaffold, 'subagent.interrupt', {
|
||||
parentSessionId: parentId,
|
||||
const interrupted = await remote<{ accepted: true }>(scaffold, 'subagents/interruptByParent', {
|
||||
childSessionId: childId,
|
||||
parentSessionId: parentId,
|
||||
mode: 'continuable',
|
||||
})
|
||||
expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } })
|
||||
@@ -169,11 +164,14 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co
|
||||
|
||||
// Only an explicit waking send resumes the parked queue, FIFO, then the
|
||||
// child runs both turns to completion and settles.
|
||||
const waking = await rpc<{ messageId: string }>(scaffold, 'subagent.prompt', {
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: WAKING }],
|
||||
const waking = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', {
|
||||
request: {
|
||||
requestId: randomUUID(),
|
||||
parentSessionId: parentId,
|
||||
childSessionId: childId,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: WAKING }],
|
||||
},
|
||||
})
|
||||
expect(waking).toMatchObject({ ok: true })
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 74834994902bd5fe1e75653ab5645aef5bf587be
|
||||
config-catalog.zh.md: 77c83f46128a54b1a8f917e82ce3c6750060fcec
|
||||
config-catalog.md: e771c16e7b0407b55806bae37caf689a9cae530d
|
||||
config-catalog.zh.md: 8f0d597b3bbbf339dad56470ee547ae51bb6494b
|
||||
|
||||
@@ -774,7 +774,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c
|
||||
|
||||
## `@deepseek-ai/dsh-host-apiproxy`
|
||||
|
||||
Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `sessionController`
|
||||
Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionQuery` · `sessionController`
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway plugin configuration. */
|
||||
|
||||
@@ -776,7 +776,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-host-apiproxy`
|
||||
|
||||
需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `sessionController`
|
||||
需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionQuery` · `sessionController`
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway plugin configuration. */
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: ff4a530f6071824da89c88a30a9744695f4d4eef
|
||||
event-producer-consumer.zh.md: 074597f038b215731e8c005afcd2fed1bc8c4bd4
|
||||
event-producer-consumer.md: bcf7ec4f3418d6b598b7edecfb2e4386f1a42e97
|
||||
event-producer-consumer.zh.md: beba6bee877989ca2fc3d33d26a989be30069c33
|
||||
|
||||
@@ -52,10 +52,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:169`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
|
||||
@@ -54,10 +54,10 @@
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:169`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: f2c8a499fc54ac90bd9b9a8a7f155ee56ad94dc7
|
||||
module-graph.zh.md: ff0d0c72020ed8cd23d6701aaac6e0125174e501
|
||||
module-graph.md: e28dc23d3baab10a5bffc6014339a6796daa845d
|
||||
module-graph.zh.md: e32626810d7208cf534a164558dc0fec0dc3bf28
|
||||
|
||||
@@ -1066,6 +1066,7 @@ flowchart TD
|
||||
pkg_subagent --> pkg_session_query
|
||||
pkg_subagent --> pkg_system_prompt
|
||||
pkg_subagent --> pkg_tools
|
||||
pkg_subagent --> pkg_typert_protocol
|
||||
pkg_subagent --> pkg_user_approval
|
||||
pkg_session_query_sqlite --> pkg_invariants
|
||||
pkg_session_query_sqlite --> pkg_session
|
||||
@@ -1302,6 +1303,7 @@ flowchart TD
|
||||
pkg_api_remotes --> pkg_session
|
||||
pkg_api_remotes --> pkg_session_reference
|
||||
pkg_api_remotes --> pkg_settings
|
||||
pkg_api_remotes --> pkg_subagent
|
||||
pkg_api_remotes --> pkg_user_approval
|
||||
pkg_api_remotes --> pkg_user_questions
|
||||
pkg_client_ui_session --> pkg_api_session_controller
|
||||
@@ -1652,6 +1654,7 @@ flowchart TD
|
||||
pkg_client_test_runtime --> pkg_client_ui_slots
|
||||
pkg_client_test_runtime --> pkg_invariants
|
||||
pkg_client_test_runtime --> pkg_session
|
||||
pkg_client_test_runtime --> pkg_subagent
|
||||
pkg_client_ui_skill --> pkg_api_remotes
|
||||
pkg_client_ui_skill --> pkg_api_session_controller
|
||||
pkg_client_ui_skill --> pkg_client_connection
|
||||
@@ -1848,7 +1851,7 @@ flowchart TD
|
||||
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
|
||||
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
|
||||
@@ -1879,7 +1882,7 @@ flowchart TD
|
||||
| [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
@@ -1918,6 +1921,6 @@ flowchart TD
|
||||
| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) |
|
||||
| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) |
|
||||
| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
|
||||
@@ -1068,6 +1068,7 @@ flowchart TD
|
||||
pkg_subagent --> pkg_session_query
|
||||
pkg_subagent --> pkg_system_prompt
|
||||
pkg_subagent --> pkg_tools
|
||||
pkg_subagent --> pkg_typert_protocol
|
||||
pkg_subagent --> pkg_user_approval
|
||||
pkg_session_query_sqlite --> pkg_invariants
|
||||
pkg_session_query_sqlite --> pkg_session
|
||||
@@ -1304,6 +1305,7 @@ flowchart TD
|
||||
pkg_api_remotes --> pkg_session
|
||||
pkg_api_remotes --> pkg_session_reference
|
||||
pkg_api_remotes --> pkg_settings
|
||||
pkg_api_remotes --> pkg_subagent
|
||||
pkg_api_remotes --> pkg_user_approval
|
||||
pkg_api_remotes --> pkg_user_questions
|
||||
pkg_client_ui_session --> pkg_api_session_controller
|
||||
@@ -1654,6 +1656,7 @@ flowchart TD
|
||||
pkg_client_test_runtime --> pkg_client_ui_slots
|
||||
pkg_client_test_runtime --> pkg_invariants
|
||||
pkg_client_test_runtime --> pkg_session
|
||||
pkg_client_test_runtime --> pkg_subagent
|
||||
pkg_client_ui_skill --> pkg_api_remotes
|
||||
pkg_client_ui_skill --> pkg_api_session_controller
|
||||
pkg_client_ui_skill --> pkg_client_connection
|
||||
@@ -1850,7 +1853,7 @@ flowchart TD
|
||||
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
|
||||
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
|
||||
@@ -1881,7 +1884,7 @@ flowchart TD
|
||||
| [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
@@ -1920,6 +1923,6 @@ flowchart TD
|
||||
| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) |
|
||||
| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) |
|
||||
| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
|
||||
subagent.md: e06d721427a195519817e4cfcf7c2bb38f572970
|
||||
subagent.zh.md: 0364360c783c1de1f6303b1a9cbc431f14db6dc2
|
||||
subagent.md: f854711f1161ba1c533fdbc43d7d6c35681f2f7f
|
||||
subagent.zh.md: 960d9b099d915fcf5b1e321ab74374664bb8e468
|
||||
|
||||
@@ -648,6 +648,53 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<Subagent
|
||||
*/
|
||||
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>
|
||||
|
||||
/**
|
||||
* Remote face of {@link listChildren} for one browser: the durable listing
|
||||
* plus live Agent activity and the delivery-time parent availability hint.
|
||||
* Parent availability is a hint; {@link prompt} performs the authoritative
|
||||
* check. Named apart from the provider-name {@link list}, which owns the
|
||||
* member.
|
||||
* @param parentSessionId - parent session whose direct children are listed.
|
||||
* @param signal - carrier cancellation forwarded to Session queries.
|
||||
* @returns the catalog view for that parent.
|
||||
* @throws {TypertRemoteFailure} `bad-request` for an empty parent id,
|
||||
* `cancelled` for an aborted read, `subagent-projections-unavailable` when
|
||||
* the deployment has no projection registry, otherwise `internal`.
|
||||
*/
|
||||
@Remote('list') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise<SubagentCatalog>
|
||||
|
||||
/**
|
||||
* Deliver one browser-authored message to a continuable child through the
|
||||
* exact live direct parent, retaining the caller-minted request identity and
|
||||
* validated browser zone on the accepted message. Success identifies the
|
||||
* message the child's FIFO inbox accepted; later execution is independent of
|
||||
* this call.
|
||||
* @param request - durable address, minted identity, content, and optional browser zone.
|
||||
* @param signal - carrier cancellation, owning the call until inbox acceptance.
|
||||
* @returns the accepted message's inbox identity.
|
||||
* @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`,
|
||||
* `subagent-parent-unavailable`, `subagent-not-resumable`,
|
||||
* `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or
|
||||
* `internal`.
|
||||
*/
|
||||
@Remote('prompt') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>
|
||||
|
||||
/**
|
||||
* Remote face of {@link interrupt} under one durable parent address. No
|
||||
* catalog, history, persistence, or parent Agent lookup runs: the core
|
||||
* primitive alone authorizes the address against the live Activation, which
|
||||
* is what keeps a live child interruptible while its parent Agent is offline.
|
||||
* Absent, idle, and already-completed targets are accepted no-ops there.
|
||||
* @param childSessionId - durable child session id to interrupt.
|
||||
* @param parentSessionId - durable direct parent whose authority is claimed.
|
||||
* @param mode - required continuable-address discriminator.
|
||||
* @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent.
|
||||
* @throws {TypertRemoteFailure} `bad-request` for an empty id,
|
||||
* `subagent-unauthorized` when the address does not own the live target,
|
||||
* otherwise `internal`.
|
||||
*/
|
||||
@Remote('interruptByParent') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: 'continuable', ): SubagentInterruptReceipt
|
||||
|
||||
/**
|
||||
* Register a provider under its name. Registration is effect-scoped and HMR
|
||||
* safe; removing a provider blocks new starts but does not revoke runs that
|
||||
|
||||
@@ -652,6 +652,53 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<Subagent
|
||||
*/
|
||||
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>
|
||||
|
||||
/**
|
||||
* Remote face of {@link listChildren} for one browser: the durable listing
|
||||
* plus live Agent activity and the delivery-time parent availability hint.
|
||||
* Parent availability is a hint; {@link prompt} performs the authoritative
|
||||
* check. Named apart from the provider-name {@link list}, which owns the
|
||||
* member.
|
||||
* @param parentSessionId - parent session whose direct children are listed.
|
||||
* @param signal - carrier cancellation forwarded to Session queries.
|
||||
* @returns the catalog view for that parent.
|
||||
* @throws {TypertRemoteFailure} `bad-request` for an empty parent id,
|
||||
* `cancelled` for an aborted read, `subagent-projections-unavailable` when
|
||||
* the deployment has no projection registry, otherwise `internal`.
|
||||
*/
|
||||
@Remote('list') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise<SubagentCatalog>
|
||||
|
||||
/**
|
||||
* Deliver one browser-authored message to a continuable child through the
|
||||
* exact live direct parent, retaining the caller-minted request identity and
|
||||
* validated browser zone on the accepted message. Success identifies the
|
||||
* message the child's FIFO inbox accepted; later execution is independent of
|
||||
* this call.
|
||||
* @param request - durable address, minted identity, content, and optional browser zone.
|
||||
* @param signal - carrier cancellation, owning the call until inbox acceptance.
|
||||
* @returns the accepted message's inbox identity.
|
||||
* @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`,
|
||||
* `subagent-parent-unavailable`, `subagent-not-resumable`,
|
||||
* `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or
|
||||
* `internal`.
|
||||
*/
|
||||
@Remote('prompt') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>
|
||||
|
||||
/**
|
||||
* Remote face of {@link interrupt} under one durable parent address. No
|
||||
* catalog, history, persistence, or parent Agent lookup runs: the core
|
||||
* primitive alone authorizes the address against the live Activation, which
|
||||
* is what keeps a live child interruptible while its parent Agent is offline.
|
||||
* Absent, idle, and already-completed targets are accepted no-ops there.
|
||||
* @param childSessionId - durable child session id to interrupt.
|
||||
* @param parentSessionId - durable direct parent whose authority is claimed.
|
||||
* @param mode - required continuable-address discriminator.
|
||||
* @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent.
|
||||
* @throws {TypertRemoteFailure} `bad-request` for an empty id,
|
||||
* `subagent-unauthorized` when the address does not own the live target,
|
||||
* otherwise `internal`.
|
||||
*/
|
||||
@Remote('interruptByParent') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: 'continuable', ): SubagentInterruptReceipt
|
||||
|
||||
/**
|
||||
* Register a provider under its name. Registration is effect-scoped and HMR
|
||||
* safe; removing a provider blocks new starts but does not revoke runs that
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:^"
|
||||
},
|
||||
@@ -97,6 +98,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 subagentsRemote from '@deepseek-ai/dsh-subagent/remote'
|
||||
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'
|
||||
@@ -22,6 +23,8 @@ 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-subagent/remote'
|
||||
export type * from '@deepseek-ai/dsh-subagent/client'
|
||||
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'
|
||||
@@ -54,7 +57,6 @@ export type {
|
||||
MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
|
||||
RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
|
||||
SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
|
||||
SubagentAddress, SubagentCatalog,
|
||||
} 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'
|
||||
@@ -110,6 +112,7 @@ export type ClientFailure =
|
||||
| import('@deepseek-ai/dsh-client-connection/client').RpcError
|
||||
| import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError
|
||||
| import('@deepseek-ai/dsh-api-session-controller/types').SessionError
|
||||
| import('@deepseek-ai/dsh-subagent/client').SubagentControlError
|
||||
| import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError
|
||||
|
||||
/** Success or failure returned by Client operations spanning both API families. */
|
||||
@@ -138,7 +141,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
for (const contribution of [
|
||||
agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote,
|
||||
pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
|
||||
sessionRemote, workspaceRemote,
|
||||
subagentsRemote, sessionRemote, workspaceRemote,
|
||||
]) {
|
||||
disposers.push(await ctx.remote.$mount(contribution))
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Client operation results spanning Session Remote calls and the legacy subagent carrier. */
|
||||
/** Client operation results spanning the Session and subagent Remote calls. */
|
||||
|
||||
import type { RpcError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SubagentControlError } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { SessionError } from '../../types.ts'
|
||||
|
||||
/** Failure surfaced by the Client Session object layer. */
|
||||
export type ClientFailure = RpcError | SessionError
|
||||
export type ClientFailure = RpcError | SessionError | SubagentControlError
|
||||
|
||||
/** Success or failure returned by a Client Session operation. */
|
||||
export type ClientResult<T> =
|
||||
@@ -13,7 +14,7 @@ export type ClientResult<T> =
|
||||
|
||||
/**
|
||||
* Fold a rejected carrier operation into the Client Session failure vocabulary.
|
||||
* @param error - rejection from a legacy subagent or local carrier call.
|
||||
* @param error - rejection from a Remote or local carrier call.
|
||||
* @returns the failure branch of a Client Session result.
|
||||
*/
|
||||
export function transportResult<T>(error: unknown): ClientResult<T> {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
import type { AgentContext } from '../scope.ts'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { ClientFailure } from './result.ts'
|
||||
|
||||
/** One transient inbox occurrence from the authoritative queue snapshot. */
|
||||
|
||||
@@ -73,6 +73,7 @@ export const inject = [
|
||||
'remote',
|
||||
'remote.commands',
|
||||
'remote.session',
|
||||
'remote.subagents',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -82,7 +83,7 @@ export const inject = [
|
||||
export function apply(ctx: Context): void {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const remotes = ctx.remote as unknown as SessionRemotes
|
||||
const sessions = new ClientSessions(ctx, connection.api, remotes)
|
||||
const sessions = new ClientSessions(ctx, remotes)
|
||||
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) => {
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
// dispatch entry + list state, constructed and held by ClientSessions (one per browser client).
|
||||
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
||||
|
||||
import type {
|
||||
IApiClient, SubagentAddress, SubagentCatalog,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
import type {
|
||||
@@ -145,11 +143,10 @@ export class SessionManager {
|
||||
})
|
||||
|
||||
/**
|
||||
* @param api - shared wire client.
|
||||
* @param remote - generated Remote namespaces the Session cluster calls.
|
||||
* @param restoredSelection - persisted real-Session selection candidate.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
private readonly remote: SessionRemotes,
|
||||
restoredSelection?: SessionId,
|
||||
restoredAddress?: SubagentAddress,
|
||||
@@ -321,7 +318,7 @@ export class SessionManager {
|
||||
const parentAvailable = address === undefined
|
||||
? undefined
|
||||
: this.catalogs.get(address.parentSessionId)?.parentAvailable
|
||||
return new Session(sessionId, this.api, this.remote, {
|
||||
return new Session(sessionId, this.remote, {
|
||||
...(address === undefined ? {} : {
|
||||
address,
|
||||
...catalogAvailability(parentAvailable),
|
||||
@@ -369,7 +366,7 @@ export class SessionManager {
|
||||
this.notifier.markDirty()
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.subagents.list({ parentSessionId })
|
||||
const result = toSessionResult(await this.remote.subagents.list(parentSessionId))
|
||||
if (result.ok) {
|
||||
const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
|
||||
?? result.value.parentAvailable
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
|
||||
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest,
|
||||
} from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { SessionRemote } from '../transport.ts'
|
||||
|
||||
@@ -21,9 +24,24 @@ export interface SessionCommandsRemote {
|
||||
): Promise<RemoteResult<object | undefined>>
|
||||
}
|
||||
|
||||
/** Narrow subagent namespace consumed by a Client Session and its manager. */
|
||||
export interface SessionSubagentsRemote {
|
||||
list(parentSessionId: SessionId, signal?: AbortSignal): Promise<RemoteResult<SubagentCatalog>>
|
||||
prompt(
|
||||
request: SubagentPromptRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteResult<SubagentPromptReceipt>>
|
||||
interruptByParent(
|
||||
childSessionId: SessionId,
|
||||
parentSessionId: SessionId,
|
||||
mode: 'continuable',
|
||||
): Promise<RemoteResult<SubagentInterruptReceipt>>
|
||||
}
|
||||
|
||||
/** Generated Remote namespaces consumed by the Client Session object layer. */
|
||||
export interface SessionRemotes {
|
||||
readonly $stream: ClientRemote['$stream']
|
||||
readonly commands: SessionCommandsRemote
|
||||
readonly session: SessionRemote
|
||||
readonly subagents: SessionSubagentsRemote
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
IApiClient, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
@@ -218,12 +216,10 @@ export class ClientSessions implements ISessions {
|
||||
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
* @param remote - generated Remote namespaces shared with every Session.
|
||||
*/
|
||||
constructor(
|
||||
private readonly rootCtx: Context,
|
||||
api: IApiClient,
|
||||
remote: SessionRemotes,
|
||||
) {
|
||||
this.selection = createSnapshotStore<SessionSelection>(
|
||||
@@ -231,7 +227,6 @@ export class ClientSessions implements ISessions {
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
const restored = this.selection.getSnapshot()
|
||||
this.manager = new SessionManager(
|
||||
api,
|
||||
remote,
|
||||
restored.sessionId,
|
||||
restored.subagentAddress,
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
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 {
|
||||
IApiClient, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
@@ -133,13 +131,11 @@ export class Session implements SessionFace {
|
||||
|
||||
/**
|
||||
* @param sessionId - Host session identity (client sessions are always Host-born).
|
||||
* @param api - shared wire client.
|
||||
* @param remote - generated Remote namespaces this session calls.
|
||||
* @param options - optional manager-owned state observers.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
private readonly remote: SessionRemotes,
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
@@ -222,13 +218,16 @@ export class Session implements SessionFace {
|
||||
},
|
||||
}
|
||||
} else {
|
||||
const routed = (await this.api.subagents.prompt({
|
||||
...this.address,
|
||||
const routed = toSessionResult(await this.remote.subagents.prompt({
|
||||
requestId: randomUUID() as SessionRequestId,
|
||||
parentSessionId: this.address.parentSessionId,
|
||||
childSessionId: this.address.childSessionId,
|
||||
mode: this.address.mode,
|
||||
content: content.flatMap(part => part.type === 'text'
|
||||
? [{ type: 'text' as const, text: part.text }]
|
||||
: []),
|
||||
clientTimeZone: resolvedClientTimeZone(),
|
||||
}, signal)).result
|
||||
}, signal))
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
}
|
||||
}
|
||||
@@ -290,7 +289,7 @@ export class Session implements SessionFace {
|
||||
/**
|
||||
* Stop the active turn while the Host preserves pending inbox work; failures
|
||||
* land in promptError (same error-strip display slot). A continuable
|
||||
* subagent address routes through `subagent.interrupt`, whose durable
|
||||
* subagent address routes through `subagents.interruptByParent`, whose durable
|
||||
* parent-address authority works without a live parent Agent; a one-shot
|
||||
* address stays uncancellable (the UI offers no stop action, so this arm is
|
||||
* defensive).
|
||||
@@ -314,7 +313,11 @@ export class Session implements SessionFace {
|
||||
let result: ClientResult<{ accepted: true }>
|
||||
try {
|
||||
result = address !== undefined
|
||||
? (await this.api.subagents.interrupt(address)).result
|
||||
? toSessionResult(await this.remote.subagents.interruptByParent(
|
||||
address.childSessionId,
|
||||
address.parentSessionId,
|
||||
address.mode,
|
||||
))
|
||||
: toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId }))
|
||||
} catch (error) {
|
||||
result = transportResult(error)
|
||||
|
||||
@@ -85,6 +85,7 @@ async function mount(initialHost?: HostDescription): Promise<Bench> {
|
||||
})
|
||||
ctx.reflect.provide('remote.commands', remote.commands)
|
||||
ctx.reflect.provide('remote.session', remote.session)
|
||||
ctx.reflect.provide('remote.subagents', remote.subagents)
|
||||
const fiber = ctx.plugin(SessionClient)
|
||||
await fiber
|
||||
const sessions = ctx.sessions as ClientSessions
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl.
|
||||
import type {
|
||||
IApiClient,
|
||||
IApiClient, MessageId,
|
||||
RpcError, RpcResponse, SessionId, SessionSearchItem, SkillEntry,
|
||||
SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {
|
||||
@@ -20,7 +21,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type { WorkspaceRemote } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-controller/types'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
RemoteStream,
|
||||
RemoteStreamError,
|
||||
@@ -84,10 +85,20 @@ export function err<T>(error: RpcError): RpcResponse<T> {
|
||||
}
|
||||
|
||||
/** Successful generated Remote result for programmable domain fakes. */
|
||||
function remoteOk<T>(value: T): RemoteResult<T> {
|
||||
export function remoteOk<T>(value: T): RemoteResult<T> {
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Failed generated Remote result carrying an owner's own failure vocabulary,
|
||||
* which the carrier's closed RPC code set does not contain.
|
||||
* @param error - the owner-declared failure.
|
||||
* @returns the failure branch of a Remote result.
|
||||
*/
|
||||
export function remoteErr<T>(error: RemoteFailure): RemoteResult<T> {
|
||||
return { ok: false, error }
|
||||
}
|
||||
|
||||
type ValueStreamItem<F> =
|
||||
| { kind: 'frame'; value: F; delivered?: () => void }
|
||||
| { kind: 'end' }
|
||||
@@ -189,19 +200,13 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
lastSearchSignal: AbortSignal | undefined
|
||||
|
||||
onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
|
||||
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
|
||||
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
|
||||
onSubagentList: (payload: unknown) => Promise<RemoteResult<SubagentCatalog>>
|
||||
= () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true }))
|
||||
onSubagentPrompt: (payload: unknown) => Promise<RemoteResult<SubagentPromptReceipt>>
|
||||
= () => Promise.resolve(remoteOk({ messageId: 'fake-message' as MessageId }))
|
||||
|
||||
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
|
||||
= () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
|
||||
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
|
||||
}
|
||||
onSubagentInterrupt: (payload: unknown) => Promise<RemoteResult<SubagentInterruptReceipt>>
|
||||
= () => Promise.resolve(remoteOk({ accepted: true as const }))
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
@@ -301,6 +306,19 @@ export class FakeApiClient implements IApiClient {
|
||||
follow: (request, signal) => this.openFollow(request, signal),
|
||||
control: signal => this.openControl(signal),
|
||||
},
|
||||
subagents: {
|
||||
list: parentSessionId => this.record(
|
||||
'subagents.list',
|
||||
parentSessionId,
|
||||
this.onSubagentList(parentSessionId),
|
||||
),
|
||||
prompt: request => this.record('subagents.prompt', request, this.onSubagentPrompt(request)),
|
||||
interruptByParent: (childSessionId, parentSessionId, mode) => this.record(
|
||||
'subagents.interruptByParent',
|
||||
{ childSessionId, parentSessionId, mode },
|
||||
this.onSubagentInterrupt({ childSessionId, parentSessionId, mode }),
|
||||
),
|
||||
},
|
||||
workspace: {
|
||||
create: payload => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
|
||||
rename: payload => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type {} from '@deepseek-ai/dsh-session-title/client'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr, remoteOk } from './fake-api.client.ts'
|
||||
import { entries, plainTurn } from './event-script.client.ts'
|
||||
|
||||
const S1 = 'fk-m1' as SessionId
|
||||
@@ -29,14 +29,14 @@ function summary(sessionId: SessionId, over: SummaryOver = {}) {
|
||||
|
||||
function makeManager(): SessionManager {
|
||||
const api = new FakeApiClient()
|
||||
return new SessionManager(api, fakeRemote(api))
|
||||
return new SessionManager(fakeRemote(api))
|
||||
}
|
||||
|
||||
describe('SessionManager instances', () => {
|
||||
it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshList()
|
||||
const session = manager.get(S1)
|
||||
expect(manager.get(S1)).toBe(session) // resident: same instance forever
|
||||
@@ -50,7 +50,7 @@ describe('list lifecycle', () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const first = manager.refreshList()
|
||||
const second = manager.refreshList()
|
||||
expect(manager.getListSnapshot().state).toBe('loading')
|
||||
@@ -66,7 +66,7 @@ describe('list lifecycle', () => {
|
||||
const api = new FakeApiClient()
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => first.promise
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const hydration = manager.refreshList()
|
||||
manager.handleSessionAdded(summary(S2, { blank: true }))
|
||||
first.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
@@ -83,7 +83,7 @@ describe('list lifecycle', () => {
|
||||
it('advances list activity from the filtered Host notification', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshList()
|
||||
|
||||
manager.handleSessionActivity(S1, 500)
|
||||
@@ -93,7 +93,7 @@ describe('list lifecycle', () => {
|
||||
it('keeps the error in the list snapshot on failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
|
||||
// A failed pull does not step the arrival phase: still pending.
|
||||
@@ -102,7 +102,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
it('phase steps pending → ready on the first successful pull and never returns', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
expect(manager.getListSnapshot().phase).toBe('pending')
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot().phase).toBe('ready')
|
||||
@@ -121,7 +121,7 @@ describe('list lifecycle', () => {
|
||||
it('merges create into the list immediately without waiting for a refresh', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const result = await manager.create()
|
||||
expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
|
||||
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
|
||||
@@ -129,7 +129,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const titleFrame = (title: string, seq: number) => {
|
||||
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq })
|
||||
}
|
||||
@@ -153,7 +153,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
// A push frame landed before the list (S2's title is newer than the block's cut).
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9,
|
||||
@@ -175,7 +175,7 @@ describe('list lifecycle', () => {
|
||||
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshList()
|
||||
const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) }
|
||||
frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
|
||||
@@ -213,7 +213,7 @@ describe('search', () => {
|
||||
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
|
||||
hasMore: true,
|
||||
}))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(manager.search('exact phrase', signal)).resolves.toEqual({
|
||||
@@ -229,7 +229,7 @@ describe('search', () => {
|
||||
|
||||
it('preserves business errors and folds transport failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
api.onSearch = () => Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'index unavailable',
|
||||
@@ -252,7 +252,7 @@ describe('search', () => {
|
||||
describe('Host Remote event routing', () => {
|
||||
it('adds/removes/flips sessions and keeps removed instances resident', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
manager.handleSessionAdded(summary(S1, { blank: true }))
|
||||
manager.handleSessionAdded(summary(S1, { blank: true })) // dup: ignored
|
||||
expect(manager.getListSnapshot().items).toHaveLength(1)
|
||||
@@ -279,14 +279,14 @@ describe('subagent catalogs', () => {
|
||||
summary(S1),
|
||||
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
|
||||
] as never[] }))
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshList()
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
|
||||
@@ -319,21 +319,23 @@ describe('subagent catalogs', () => {
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('subagent.history')).toEqual([])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([
|
||||
expect(api.callsOf('subagents.prompt')).toEqual([
|
||||
{
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
|
||||
requestId: expect.any(String) as unknown as string,
|
||||
parentSessionId: S1, childSessionId: S2,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: 'continue' }],
|
||||
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
const listCalls = api.callsOf('subagent.list').length
|
||||
const listCalls = api.callsOf('subagents.list').length
|
||||
manager.handleSessionStatus(S2, false)
|
||||
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
|
||||
kind: 'child', id: S2, activity: 'inactive',
|
||||
})
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(listCalls)
|
||||
expect(api.callsOf('subagents.list')).toHaveLength(listCalls)
|
||||
|
||||
manager.handleSessionRemoved(S2)
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
|
||||
@@ -351,20 +353,20 @@ describe('subagent catalogs', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.setSubagentCatalogOpen(S1, true)
|
||||
await Promise.resolve()
|
||||
const baseline = api.callsOf('subagent.list').length
|
||||
const baseline = api.callsOf('subagents.list').length
|
||||
manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
|
||||
manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 }))
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
|
||||
expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1)
|
||||
|
||||
manager.setSubagentCatalogOpen(S1, false)
|
||||
manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 }))
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
|
||||
expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -373,7 +375,7 @@ describe('subagent catalogs', () => {
|
||||
it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
@@ -386,7 +388,7 @@ describe('subagent catalogs', () => {
|
||||
] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshSubagents(root)
|
||||
|
||||
manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
|
||||
@@ -405,13 +407,13 @@ describe('subagent catalogs', () => {
|
||||
const root = 'fk-root' as SessionId
|
||||
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => response.promise
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
|
||||
parentSessionId: S1, origin: 'subagent',
|
||||
}))
|
||||
response.resolve(ok({
|
||||
response.resolve(remoteOk({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
@@ -424,7 +426,7 @@ describe('subagent catalogs', () => {
|
||||
{ kind: 'child', id: S1, hasChildren: true },
|
||||
])
|
||||
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
@@ -442,12 +444,12 @@ describe('subagent catalogs', () => {
|
||||
const root = 'fk-root' as SessionId
|
||||
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => response.promise
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
manager.handleSessionStatus(S1, false)
|
||||
manager.handleSessionStatus(S2, true)
|
||||
response.resolve(ok({
|
||||
response.resolve(remoteOk({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
|
||||
@@ -470,14 +472,14 @@ describe('subagent catalogs', () => {
|
||||
|
||||
it('marks a detached catalog child inactive without requiring a selected address', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshSubagents(S1)
|
||||
|
||||
manager.handleSessionRemoved(S2)
|
||||
@@ -492,15 +494,15 @@ describe('subagent catalogs', () => {
|
||||
const root = 'fk-root' as SessionId
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
expect(manager.refreshSubagents(root)).toBe(refresh)
|
||||
api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
first.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true }))
|
||||
first.resolve(remoteOk({ entries: [], parentAvailable: true }))
|
||||
await refresh
|
||||
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(1)
|
||||
expect(api.callsOf('subagents.list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => {
|
||||
@@ -511,7 +513,7 @@ describe('subagent catalogs', () => {
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api, fakeRemote(api), root)
|
||||
const manager = new SessionManager(fakeRemote(api), root)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
manager.setSubagentCatalogOpen(root, true)
|
||||
|
||||
@@ -522,7 +524,7 @@ describe('subagent catalogs', () => {
|
||||
manager.handleSessionAdded(summary(S2, { parentSessionId: root }))
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
api.onSubagentList = () => second.promise
|
||||
first.resolve(ok({
|
||||
first.resolve(remoteOk({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'older',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
@@ -531,7 +533,7 @@ describe('subagent catalogs', () => {
|
||||
}))
|
||||
await refresh
|
||||
// The trailing pull is already in flight (kicked synchronously in finally).
|
||||
second.resolve(ok({
|
||||
second.resolve(remoteOk({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'older',
|
||||
@@ -545,8 +547,10 @@ describe('subagent catalogs', () => {
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await second.promise
|
||||
// The Remote face resolves one microtask after the response settles.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(2)
|
||||
expect(api.callsOf('subagents.list')).toHaveLength(2)
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, label: 'older' },
|
||||
{ kind: 'child', id: S2, label: 'new child' },
|
||||
@@ -565,9 +569,9 @@ describe('subagent catalogs', () => {
|
||||
})
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
|
||||
first.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true }))
|
||||
await refresh
|
||||
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
|
||||
|
||||
@@ -579,12 +583,12 @@ describe('subagent catalogs', () => {
|
||||
manager.handleSessionRemoved(root)
|
||||
const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => trailing.promise
|
||||
mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
|
||||
mid.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true }))
|
||||
await midRefresh
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
|
||||
|
||||
trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} }))
|
||||
trailing.resolve(remoteErr({ code: 'internal', message: 'trailing pull failed', details: {} }))
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
|
||||
state: 'error',
|
||||
@@ -592,8 +596,7 @@ describe('subagent catalogs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const rootCalls = api.callsOf('subagent.list')
|
||||
.filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root)
|
||||
const rootCalls = api.callsOf('subagents.list').filter(call => call === root)
|
||||
expect(rootCalls).toHaveLength(3)
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
|
||||
@@ -602,14 +605,14 @@ describe('subagent catalogs', () => {
|
||||
it('invalidates catalog availability when the owning parent is removed', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshSubagents(root)
|
||||
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
|
||||
@@ -625,14 +628,14 @@ describe('remaining branches', () => {
|
||||
it('refreshList folds a transport throw into the error state', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.reject(new Error('list wire down'))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
|
||||
})
|
||||
|
||||
it('refreshList pushes running bits down to already-instantiated sessions', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const session = manager.get(S1)
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
|
||||
await manager.refreshList()
|
||||
@@ -642,7 +645,7 @@ describe('remaining branches', () => {
|
||||
it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.create({ cwd: '/tmp/w', sessionId: S1 })
|
||||
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
|
||||
@@ -662,7 +665,7 @@ describe('remaining branches', () => {
|
||||
message: 'published but unattached',
|
||||
details: { sessionId: S1, workspaceId: 'w1' },
|
||||
} as never))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
|
||||
@@ -676,7 +679,7 @@ describe('remaining branches', () => {
|
||||
message: 'forked but unattached',
|
||||
details: { sessionId: S2, workspaceId: 'w1' },
|
||||
} as never))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const result = await manager.fork({ sessionId: S1 })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
|
||||
@@ -689,7 +692,7 @@ describe('remaining branches', () => {
|
||||
it('reconciles a preallocated id after an ordinary transport failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.reject(new Error('response lost'))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
|
||||
expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([])
|
||||
@@ -704,7 +707,7 @@ describe('remaining branches', () => {
|
||||
|
||||
it('subscribe notifies on list changes and stops after unsubscribe', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
let notified = 0
|
||||
const unsubscribe = manager.subscribe(() => { notified++ })
|
||||
await manager.refreshList()
|
||||
@@ -719,7 +722,7 @@ describe('remaining branches', () => {
|
||||
|
||||
it('ignores Host status and error events for sessions without an instance', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
manager.handleSessionStatus(S2, true)
|
||||
manager.handleSessionError(S2, '无实例')
|
||||
})
|
||||
@@ -727,7 +730,7 @@ describe('remaining branches', () => {
|
||||
it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshList()
|
||||
const before = manager.getListSnapshot()
|
||||
manager.handleSessionStatus(S2, true)
|
||||
@@ -743,7 +746,7 @@ describe('remaining branches', () => {
|
||||
|
||||
it('carries parentSessionId from the added event into the lineage row', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
manager.handleSessionAdded(summary(S1, { blank: true }))
|
||||
manager.handleSessionAdded(summary(S2, {
|
||||
blank: true, parentSessionId: S1, origin: 'subagent',
|
||||
@@ -763,7 +766,7 @@ describe('connected generation', () => {
|
||||
hasMore: false,
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
}))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const openedSession = manager.get(S1)
|
||||
await openedSession.open()
|
||||
manager.get(S2) // instantiated but never opened
|
||||
@@ -782,26 +785,19 @@ describe('connected generation', () => {
|
||||
}
|
||||
const parent = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
const child = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = payload => (
|
||||
(payload as { parentSessionId: SessionId }).parentSessionId === S1
|
||||
? parent.promise
|
||||
: child.promise
|
||||
)
|
||||
const manager = new SessionManager(api, fakeRemote(api), S2, address)
|
||||
api.onSubagentList = payload => (payload === S1 ? parent.promise : child.promise)
|
||||
const manager = new SessionManager(fakeRemote(api), S2, address)
|
||||
|
||||
manager.handleConnected()
|
||||
expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
|
||||
parent.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
child.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
parent.resolve(remoteOk({ entries: [], parentAvailable: true }))
|
||||
child.resolve(remoteOk({ entries: [], parentAvailable: true }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('session.list')).toHaveLength(1)
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('subagent.list')).toEqual([
|
||||
{ parentSessionId: S1 },
|
||||
{ parentSessionId: S2 },
|
||||
])
|
||||
expect(api.callsOf('subagents.list')).toEqual([S1, S2])
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot().subagent).toEqual({
|
||||
address,
|
||||
@@ -882,7 +878,7 @@ describe('completed reminder', () => {
|
||||
it('a list refresh carrying the running→idle transition arms the reminder', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshList()
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
@@ -894,7 +890,7 @@ describe('completed reminder', () => {
|
||||
it('never arms for sessions already idle at first observation', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshList()
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
@@ -907,7 +903,7 @@ describe('completed reminder', () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const refresh = manager.refreshList()
|
||||
// The session finishes while the first pull is still in flight; the pull
|
||||
// response recorded it as running at pull time.
|
||||
@@ -921,7 +917,7 @@ describe('completed reminder', () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const refresh = manager.refreshList()
|
||||
// The unknown session starts and finishes while the first pull is in
|
||||
// flight; the pull-time baseline recorded it idle, so the running→idle
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('Session projection value 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(api))
|
||||
const session = new Session(SID, fakeRemote(api))
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
records: 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(api))
|
||||
const session = new Session(SID, fakeRemote(api))
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
records: 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(api))
|
||||
const session = new Session(SID, fakeRemote(api))
|
||||
api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
|
||||
await session.open()
|
||||
session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
|
||||
@@ -141,7 +141,7 @@ describe('manager frame routing', () => {
|
||||
|
||||
it('lands projection frames before instantiation and the Session adopts the same store', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
manager.handleControlFrame({
|
||||
type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7,
|
||||
})
|
||||
@@ -156,7 +156,7 @@ describe('manager frame routing', () => {
|
||||
|
||||
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(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
@@ -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(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{
|
||||
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
|
||||
@@ -208,7 +208,7 @@ describe('manager frame routing', () => {
|
||||
|
||||
it('drops the projection store with the removed session', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api, fakeRemote(api))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
|
||||
@@ -48,12 +48,12 @@ function makeSession(): Session {
|
||||
|
||||
function makeBench(): { api: FakeApiClient; session: Session } {
|
||||
const api = new FakeApiClient()
|
||||
return { api, session: new Session(SID, api, fakeRemote(api)) }
|
||||
return { api, session: new Session(SID, fakeRemote(api)) }
|
||||
}
|
||||
|
||||
function makeManager(): SessionManager {
|
||||
const api = new FakeApiClient()
|
||||
return new SessionManager(api, fakeRemote(api))
|
||||
return new SessionManager(fakeRemote(api))
|
||||
}
|
||||
|
||||
describe('Session queue snapshot intake', () => {
|
||||
@@ -210,7 +210,7 @@ describe('Session queue snapshot intake', () => {
|
||||
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(api))
|
||||
const session = new Session(SID, fakeRemote(api))
|
||||
session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }]))
|
||||
const before = session.getSnapshot().queue
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr } from './fake-api.client.ts'
|
||||
import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
@@ -19,7 +19,7 @@ function makeSession(
|
||||
api = new FakeApiClient(),
|
||||
options: SessionOptions = {},
|
||||
): { api: FakeApiClient; session: Session } {
|
||||
return { api, session: new Session(SID, api, fakeRemote(api), options) }
|
||||
return { api, session: new Session(SID, fakeRemote(api), options) }
|
||||
}
|
||||
|
||||
function follow(
|
||||
@@ -239,7 +239,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(api), {
|
||||
const session = new Session(SID, fakeRemote(api), {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
@@ -258,15 +258,17 @@ describe('prompt and cancel errors', () => {
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('subagent.history')).toEqual([])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([
|
||||
expect(api.callsOf('subagents.prompt')).toEqual([
|
||||
{
|
||||
parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
|
||||
requestId: expect.any(String) as unknown as string,
|
||||
parentSessionId: PARENT, childSessionId: SID,
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: '继续' }],
|
||||
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('subagent.interrupt')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
expect(api.callsOf('subagents.interruptByParent')).toEqual([
|
||||
{ childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' },
|
||||
])
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
@@ -281,10 +283,10 @@ describe('prompt and cancel errors', () => {
|
||||
|
||||
it('lands an interrupt business failure in promptError with op=stop', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSubagentInterrupt = () => Promise.resolve(err({
|
||||
api.onSubagentInterrupt = () => Promise.resolve(remoteErr({
|
||||
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
|
||||
}) as never)
|
||||
const session = new Session(SID, api, fakeRemote(api), {
|
||||
}))
|
||||
const session = new Session(SID, fakeRemote(api), {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
@@ -298,7 +300,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(api), {
|
||||
const session = new Session(SID, fakeRemote(api), {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
|
||||
})
|
||||
await session.open()
|
||||
@@ -316,8 +318,8 @@ describe('prompt and cancel errors', () => {
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('subagent.history')).toEqual([])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([])
|
||||
expect(api.callsOf('subagent.interrupt')).toEqual([])
|
||||
expect(api.callsOf('subagents.prompt')).toEqual([])
|
||||
expect(api.callsOf('subagents.interruptByParent')).toEqual([])
|
||||
expect(api.callsOf('session.cancel')).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
err,
|
||||
fakeRemote,
|
||||
ok,
|
||||
remoteOk,
|
||||
type RuntimeRemotes,
|
||||
} from './fake-api.client.ts'
|
||||
|
||||
@@ -33,7 +34,7 @@ function bench(configureRemote?: (remote: RuntimeRemotes) => RuntimeRemotes): Be
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const remote = fakeRemote(api)
|
||||
const svc = new ClientSessions(ctx, api, configureRemote?.(remote) ?? remote)
|
||||
const svc = new ClientSessions(ctx, configureRemote?.(remote) ?? remote)
|
||||
return { ctx, api, svc }
|
||||
}
|
||||
|
||||
@@ -522,9 +523,9 @@ describe('catalog-addressed navigation', () => {
|
||||
it('uses catalog labels for a listed addressed route', async () => {
|
||||
const b = bench()
|
||||
b.api.onSubagentList = (payload) => {
|
||||
const { parentSessionId } = payload as { parentSessionId: SessionId }
|
||||
const parentSessionId = payload as SessionId
|
||||
if (parentSessionId === sid('root')) {
|
||||
return Promise.resolve(ok({
|
||||
return Promise.resolve(remoteOk({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
|
||||
activity: 'inactive', hasChildren: true,
|
||||
@@ -533,7 +534,7 @@ describe('catalog-addressed navigation', () => {
|
||||
}))
|
||||
}
|
||||
if (parentSessionId === sid('child')) {
|
||||
return Promise.resolve(ok({
|
||||
return Promise.resolve(remoteOk({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
@@ -541,7 +542,7 @@ describe('catalog-addressed navigation', () => {
|
||||
parentAvailable: false,
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
|
||||
return Promise.resolve(remoteOk({ entries: [], parentAvailable: false }))
|
||||
}
|
||||
await feedList(b, [
|
||||
{ id: 'root' },
|
||||
@@ -561,9 +562,9 @@ describe('catalog-addressed navigation', () => {
|
||||
it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
|
||||
const b = bench()
|
||||
b.api.onSubagentList = (payload) => {
|
||||
const { parentSessionId } = payload as { parentSessionId: SessionId }
|
||||
const parentSessionId = payload as SessionId
|
||||
if (parentSessionId === sid('root')) {
|
||||
return Promise.resolve(ok({
|
||||
return Promise.resolve(remoteOk({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
|
||||
activity: 'inactive', hasChildren: true,
|
||||
@@ -572,7 +573,7 @@ describe('catalog-addressed navigation', () => {
|
||||
}))
|
||||
}
|
||||
if (parentSessionId === sid('child')) {
|
||||
return Promise.resolve(ok({
|
||||
return Promise.resolve(remoteOk({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
@@ -580,7 +581,7 @@ describe('catalog-addressed navigation', () => {
|
||||
parentAvailable: false,
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
|
||||
return Promise.resolve(remoteOk({ entries: [], parentAvailable: false }))
|
||||
}
|
||||
await feedList(b, [{ id: 'root' }])
|
||||
await b.svc.refreshSubagents(sid('root'))
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../session/session-projection" },
|
||||
{ "path": "../../session/session-title" },
|
||||
{ "path": "../../subagent/subagent" },
|
||||
{ "path": "../../util/brand" },
|
||||
{ "path": "../../util/crypto" },
|
||||
{ "path": "../../util/workspace-path" },
|
||||
|
||||
@@ -14,7 +14,6 @@ export type {
|
||||
ModelReasoningEffort, ModelSelection,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
|
||||
@@ -3260,13 +3260,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
}
|
||||
|
||||
const api: ApiProxy = {
|
||||
subagents: {
|
||||
list: request => ok(request, { entries: [], parentAvailable: true }),
|
||||
prompt: request => Promise.resolve(ok(request, {
|
||||
messageId: `fixture-message-${request.payload.childSessionId}` as never,
|
||||
})),
|
||||
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, {
|
||||
version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true,
|
||||
@@ -3469,6 +3462,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
case 'agentPresets/read': return Promise.resolve(presetRemotes.read(args.agentPreset as string))
|
||||
case 'agentPresets/copy': return Promise.resolve(presetRemotes.copy(args.from as string, args.id as string))
|
||||
case 'agentPresets/deletePreset': return Promise.resolve(presetRemotes.deletePreset(args.id as string))
|
||||
case 'subagents/list': return Promise.resolve({
|
||||
ok: true,
|
||||
value: { entries: [], parentAvailable: true },
|
||||
})
|
||||
case 'subagents/prompt': return Promise.resolve({
|
||||
ok: true,
|
||||
value: {
|
||||
messageId: `fixture-message-${(request as { childSessionId: SessionId }).childSessionId}`,
|
||||
},
|
||||
})
|
||||
case 'subagents/interruptByParent': return Promise.resolve({ ok: true, value: { accepted: true } })
|
||||
case 'session/list': return sessionApi.list(
|
||||
args._request as Parameters<FixtureSessionApi['list']>[0],
|
||||
)
|
||||
@@ -3591,9 +3595,6 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<unknown>> {
|
||||
switch (method) {
|
||||
case 'subagent.list': return this.api.subagents.list(request)
|
||||
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
|
||||
case 'subagent.interrupt': return this.api.subagents.interrupt(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
|
||||
|
||||
@@ -35,7 +35,6 @@ export type {
|
||||
SkillsApi, SkillEntry,
|
||||
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
MessageId, ModelReasoningEffort, ModelSelection,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, RpcMessage,
|
||||
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
|
||||
@@ -69,19 +69,6 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
private readonly generationConns: StreamConn[] = []
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({
|
||||
entries: [],
|
||||
parentAvailable: true,
|
||||
}))),
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
|
||||
messageId: 'fake-message' as never,
|
||||
}))),
|
||||
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({
|
||||
accepted: true as const,
|
||||
}))),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
|
||||
README.md: d77c6d0b8284047f88cb2c671c337328dce6b635
|
||||
README.zh.md: 0610500577c257af58bcaa077dd20a1fc0f09840
|
||||
README.md: 3c7d185123d76270844d17dcb0e070e27f33ef7d
|
||||
README.zh.md: cf022bb0d121805f9b322e84fb77c6dac412fad0
|
||||
|
||||
@@ -33,7 +33,7 @@ Rows display mode plus `running`/`inactive` activity and an optional log-backed
|
||||
|
||||
### Continuing a conversation
|
||||
|
||||
A continuable child with a live parent keeps the ordinary input chrome: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, and an independent Stop routes through `subagent.interrupt`. A continuable child whose exact parent is unavailable and which is not running elects a read-only composer explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer with input and Send disabled but its independent Stop usable.
|
||||
A continuable child with a live parent keeps the ordinary input chrome: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, and an independent Stop routes through `subagents/interruptByParent`. A continuable child whose exact parent is unavailable and which is not running elects a read-only composer explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer with input and Send disabled but its independent Stop usable.
|
||||
|
||||
### The `@` reference source
|
||||
|
||||
@@ -59,7 +59,7 @@ Token totals sum the four disjoint `tokenUsage` buckets. Duration sums completed
|
||||
|
||||
### Composer election
|
||||
|
||||
One-shot children always elect a read-only composer. A continuable child elects one only when its exact parent is unavailable and the child is not running; otherwise the ordinary composer's Session routes prompts through `subagent.prompt`. This package never receives host context or calls a model-facing tool.
|
||||
One-shot children always elect a read-only composer. A continuable child elects one only when its exact parent is unavailable and the child is not running; otherwise the ordinary composer's Session routes prompts through `subagents/prompt`. This package never receives host context or calls a model-facing tool.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ kind: "package-reference"
|
||||
|
||||
### 续接对话
|
||||
|
||||
确切 parent 存活时,可继续 child 保留普通输入 chrome:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagent.interrupt` 路由。确切 parent 不可用且 child 未在运行的可继续 child 会选用说明恢复路径的只读编辑器;此类 child 仍在运行期间,selector 会让位给普通编辑器——输入区与 Send 被禁用,但独立的 Stop 保持可用。
|
||||
确切 parent 存活时,可继续 child 保留普通输入 chrome:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagents/interruptByParent` 路由。确切 parent 不可用且 child 未在运行的可继续 child 会选用说明恢复路径的只读编辑器;此类 child 仍在运行期间,selector 会让位给普通编辑器——输入区与 Send 被禁用,但独立的 Stop 保持可用。
|
||||
|
||||
### `@` 引用 source
|
||||
|
||||
@@ -59,7 +59,7 @@ token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。耗时会累
|
||||
|
||||
### 编辑器选举
|
||||
|
||||
one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器;否则普通编辑器的会话会经 `subagent.prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。
|
||||
one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器;否则普通编辑器的会话会经 `subagents/prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type SessionListState, type SessionProjectionMap, type SessionSummary,
|
||||
type SubagentCatalogSnapshot,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NS } from './locales.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-subagent/client'
|
||||
import type {} from '@deepseek-ai/dsh-token-meter/client'
|
||||
import css from './SubagentHeaderLineage.module.css'
|
||||
import { indexSubagentDescendants } from './subagent-lineage.ts'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Web subagent catalog, navigation, and addressed-session composer owner. */
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { SubagentHeaderLineage, type SubagentCatalogInjected } from './SubagentHeaderLineage.tsx'
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
SessionListState, SessionSnapshot, SessionSummary,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
@@ -185,7 +185,6 @@ class FakeApiClient implements IApiClient {
|
||||
onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' }))
|
||||
onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true }))
|
||||
|
||||
declare readonly subagents: IApiClient['subagents']
|
||||
declare readonly skills: IApiClient['skills']
|
||||
declare readonly agentPresets: IApiClient['agentPresets']
|
||||
declare readonly settings: IApiClient['settings']
|
||||
|
||||
@@ -2073,6 +2073,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
returns: 'children and per-candidate diagnostics with tree position, in stable pre-order.',
|
||||
throws: ['{@link SubagentError} under the same conditions as {@link listChildren}.'],
|
||||
},
|
||||
{
|
||||
signature: '@Remote(\'list\') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise<SubagentCatalog>',
|
||||
description: 'Remote face of listChildren for one browser: the durable listing plus live Agent activity and the delivery-time parent availability hint. Parent availability is a hint; prompt performs the authoritative check. Named apart from the provider-name list, which owns the member.',
|
||||
parameters: [{ name: 'parentSessionId', description: 'parent session whose direct children are listed.' }, { name: 'signal', description: 'carrier cancellation forwarded to Session queries.' }],
|
||||
returns: 'the catalog view for that parent.',
|
||||
throws: ['{TypertRemoteFailure} `bad-request` for an empty parent id, `cancelled` for an aborted read, `subagent-projections-unavailable` when the deployment has no projection registry, otherwise `internal`.'],
|
||||
},
|
||||
{
|
||||
signature: '@Remote(\'prompt\') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>',
|
||||
description: 'Deliver one browser-authored message to a continuable child through the exact live direct parent, retaining the caller-minted request identity and validated browser zone on the accepted message. Success identifies the message the child\'s FIFO inbox accepted; later execution is independent of this call.',
|
||||
parameters: [{ name: 'request', description: 'durable address, minted identity, content, and optional browser zone.' }, { name: 'signal', description: 'carrier cancellation, owning the call until inbox acceptance.' }],
|
||||
returns: 'the accepted message\'s inbox identity.',
|
||||
throws: ['{TypertRemoteFailure} `bad-request`, `invalid-time-zone`, `subagent-parent-unavailable`, `subagent-not-resumable`, `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or `internal`.'],
|
||||
},
|
||||
{
|
||||
signature: '@Remote(\'interruptByParent\') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\', ): SubagentInterruptReceipt',
|
||||
description: 'Remote face of interrupt under one durable parent address. No catalog, history, persistence, or parent Agent lookup runs: the core primitive alone authorizes the address against the live Activation, which is what keeps a live child interruptible while its parent Agent is offline. Absent, idle, and already-completed targets are accepted no-ops there.',
|
||||
parameters: [{ name: 'childSessionId', description: 'durable child session id to interrupt.' }, { name: 'parentSessionId', description: 'durable direct parent whose authority is claimed.' }, { name: 'mode', description: 'required continuable-address discriminator.' }],
|
||||
returns: 'acknowledgement that the cancel signal was admitted, not that the target is quiescent.',
|
||||
throws: ['{TypertRemoteFailure} `bad-request` for an empty id, `subagent-unauthorized` when the address does not own the live target, otherwise `internal`.'],
|
||||
},
|
||||
{
|
||||
signature: 'registerProvider(provider: SubagentProvider): () => void',
|
||||
description: 'Register a provider under its name. Registration is effect-scoped and HMR safe; removing a provider blocks new starts but does not revoke runs that were already returned to their holders.',
|
||||
@@ -4401,7 +4422,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'RpcErrorDetailsMap',
|
||||
declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'directory-unreadable\': {\n path: string;\n };\n \'directory-exists\': {\n path: string;\n };\n \'directory-create-failed\': {\n path: string;\n };\n \'directory-picker-unavailable\': {\n capability: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'subagent-parent-unavailable\': {\n parentSessionId: SessionId;\n };\n \'subagent-not-found\': {\n parentSessionId: SessionId;\n childSessionId: SessionId;\n };\n \'subagent-catalog-diagnostic\': {\n parentSessionId: SessionId;\n childS /* …truncated — full shape in source */',
|
||||
declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'directory-unreadable\': {\n path: string;\n };\n \'directory-exists\': {\n path: string;\n };\n \'directory-create-failed\': {\n path: string;\n };\n \'directory-picker-unavailable\': {\n capability: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}',
|
||||
},
|
||||
{
|
||||
name: 'RpcId',
|
||||
@@ -5063,6 +5084,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SubagentCapabilities',
|
||||
declaration: 'export interface SubagentCapabilities {\n readonly agentOptions: boolean;\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubagentCatalog',
|
||||
declaration: 'export interface SubagentCatalog {\n readonly entries: readonly SubagentListEntry[];\n readonly parentAvailable: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubagentDescendantListEntry',
|
||||
declaration: 'export type SubagentDescendantListEntry = SubagentListEntry & {\n readonly parentId: SessionId;\n readonly depth: number;\n};',
|
||||
@@ -5079,6 +5104,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SubagentInterruptAuthority',
|
||||
declaration: 'export type SubagentInterruptAuthority = {\n readonly kind: \'user\';\n readonly parentSessionId: SessionId;\n} | {\n readonly kind: \'ancestor\';\n readonly agent: Agent;\n};',
|
||||
},
|
||||
{
|
||||
name: 'SubagentInterruptReceipt',
|
||||
declaration: 'export interface SubagentInterruptReceipt {\n readonly accepted: true;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubagentListEntry',
|
||||
declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'SubagentPromptReceipt',
|
||||
declaration: 'export interface SubagentPromptReceipt {\n readonly messageId: MessageId;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubagentPromptRequest',
|
||||
declaration: 'export interface SubagentPromptRequest {\n readonly requestId: SubagentPromptRequestId;\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'continuable\';\n readonly content: ContentBlock[];\n readonly clientTimeZone?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubagentPromptRequestId',
|
||||
declaration: 'export type SubagentPromptRequestId = Branded<\'session-request-id\'>;',
|
||||
},
|
||||
{
|
||||
name: 'SubagentProvider',
|
||||
declaration: 'export interface SubagentProvider {\n readonly name: string;\n readonly capabilities: SubagentCapabilities;\n readonly inheritsParentContext: boolean;\n readonly agentRouteDefaults?: Readonly<{\n provider: string;\n model: string;\n }>;\n start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>;\n prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>;\n}',
|
||||
@@ -5113,7 +5158,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SubagentRuntime',
|
||||
declaration: 'export class SubagentRuntime extends Service {\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart>;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise<MessageId>;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise<MessageId>;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>;\n}',
|
||||
declaration: 'export class SubagentRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart>;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise<MessageId>;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise<MessageId>;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>;\n @Remote(\'list\')\n async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise<SubagentCatalog>;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | un /* …truncated — full shape in source */',
|
||||
},
|
||||
{
|
||||
name: 'SubagentStartRequest',
|
||||
@@ -5495,6 +5540,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'TypertEventModel',
|
||||
declaration: 'export interface TypertEventModel extends TypertDocumentation {\n readonly name: string;\n readonly mode?: string;\n readonly signature: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertGatewayBinding',
|
||||
declaration: 'export interface TypertGatewayBinding<Service extends object = object> {\n readonly service: Service;\n readonly serviceKey: string;\n readonly namespace: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertGatewayWireStream',
|
||||
declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<AsyncIterable<unknown>>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}',
|
||||
@@ -5543,6 +5592,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'TypertRemoteEventSource',
|
||||
declaration: 'export type TypertRemoteEventSource = (signal: AbortSignal) => AsyncIterable<TypertRemoteEventDispatch>;',
|
||||
},
|
||||
{
|
||||
name: 'TypertRemoteService',
|
||||
declaration: 'export abstract class TypertRemoteService<out T = never> extends Service<T> {\n readonly typertRemote: TypertGatewayBinding<this>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertSchemaFilter',
|
||||
declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 3e11f00400a2f954a27ce957bd2bf95f8bd8932d
|
||||
README.zh.md: aea58628e40efad282daf5ddbc412e0cf8471872
|
||||
README.md: 3460d0ec021f658fe91e1ed8b617196d437cb937
|
||||
README.zh.md: a6cfac365fe728f26b3afd22ef32e661bd0146ff
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Every client of the web GUI host calls one typed API through `dsh-host-apiproxy` — sessions and history, subagents, workspaces, directory picking, model selection, agent presets, skills, goals, settings, credentials, LLM catalogs, events, and session export — moved over HTTP or in-process by fetch carriers. The contract layer has zero Node dependencies and imports from the browser, so one typed API serves the Web server, Electron, and any future client shape. The shipped Web composition assembles the gateway in [`dsh-web-app`](../../bundle/web-app/README.md). Choosing a carrier, calling the domain APIs, and configuring the gateway come first; the wire protocol internals live in a collapsible developer section below.
|
||||
Every client of the web GUI host calls one typed API through `dsh-host-apiproxy` — sessions and history, workspaces, directory picking, model selection, agent presets, skills, goals, settings, credentials, LLM catalogs, events, and session export — moved over HTTP or in-process by fetch carriers. The contract layer has zero Node dependencies and imports from the browser, so one typed API serves the Web server, Electron, and any future client shape. The shipped Web composition assembles the gateway in [`dsh-web-app`](../../bundle/web-app/README.md). Choosing a carrier, calling the domain APIs, and configuring the gateway come first; the wire protocol internals live in a collapsible developer section below.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -40,7 +40,7 @@ The HTTP carrier refuses non-JSON POST bodies with 415 before dispatch, so cross
|
||||
|
||||
### What the gateway exposes
|
||||
|
||||
The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `subagents`, `workspace`, `host` (describe, pickDirectory, listDirectory, createDirectory, openPath), `skills`, `agentPresets`, `goals`, `settings`, `credentials`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`.
|
||||
The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `workspace`, `host` (describe, pickDirectory, listDirectory, createDirectory, openPath), `skills`, `agentPresets`, `goals`, `settings`, `credentials`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`.
|
||||
|
||||
### Sessions and history
|
||||
|
||||
@@ -92,7 +92,7 @@ The package is built on one separation: the API contract is channel-independent,
|
||||
|
||||
### The gateway service
|
||||
|
||||
`ApiProxyService` provides `ctx.apiProxy` and implements the contract over the composed host context — sessions, subagents, workspace registry, directory picker, agent presets, settings, credentials, LLM, events, and downloads. The Host cwd is the default project directory. The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata `host.describe` reports; `session.selectModel` (Session Controller) saves an accepted switch as the deployment default through the shared agent-default-model settings section. Product `dsh --profile headless` is a direct core entry point and does not mount this package.
|
||||
`ApiProxyService` provides `ctx.apiProxy` and implements the contract over the composed host context — sessions, workspace registry, directory picker, agent presets, settings, credentials, LLM, events, and downloads. The Host cwd is the default project directory. The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata `host.describe` reports; `session.selectModel` (Session Controller) saves an accepted switch as the deployment default through the shared agent-default-model settings section. Product `dsh --profile headless` is a direct core entry point and does not mount this package.
|
||||
|
||||
### Request flow
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
web GUI 宿主的每个客户端都通过 `dsh-host-apiproxy` 调用同一套类型化 API——会话与历史、子代理、工作区、目录选择、模型选择、agent preset、skill、目标、设置、凭据、LLM 目录、事件与会话导出——由 fetch 载体经由 HTTP 或进程内搬运。约定层零 Node 依赖、可从浏览器导入,因此一套类型化 API 同时服务 Web 服务器、Electron 与任何未来的客户端形态。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装网关。选择载体、调用领域 API 与配置网关在前;协议内部细节放在下方可折叠的开发者章节中。
|
||||
web GUI 宿主的每个客户端都通过 `dsh-host-apiproxy` 调用同一套类型化 API——会话与历史、工作区、目录选择、模型选择、agent preset、skill、目标、设置、凭据、LLM 目录、事件与会话导出——由 fetch 载体经由 HTTP 或进程内搬运。约定层零 Node 依赖、可从浏览器导入,因此一套类型化 API 同时服务 Web 服务器、Electron 与任何未来的客户端形态。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装网关。选择载体、调用领域 API 与配置网关在前;协议内部细节放在下方可折叠的开发者章节中。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -40,7 +40,7 @@ HTTP 载体在分发前以 415 拒绝非 JSON 的 POST 请求体,因此跨站
|
||||
|
||||
### 网关暴露什么
|
||||
|
||||
API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`subagents`、`workspace`、`host`(describe、pickDirectory、listDirectory、createDirectory、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`credentials`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。
|
||||
API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`workspace`、`host`(describe、pickDirectory、listDirectory、createDirectory、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`credentials`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。
|
||||
|
||||
### 会话与历史
|
||||
|
||||
@@ -92,7 +92,7 @@ API 按领域分组:`sessions`(list、create、history、prompt、cancel、q
|
||||
|
||||
### 网关服务
|
||||
|
||||
`ApiProxyService` 提供 `ctx.apiProxy`,并基于所组合的宿主上下文实现约定——会话、子代理、工作区注册表、目录选择器、agent preset、设置、凭据、LLM、事件与下载。Host cwd 是默认项目目录。网关只在 `host.describe` 报告的部署元数据中消费 `ctx.agentDefaultModel`;保存已接受的切换由 Session Controller 的 `session.selectModel` 通过共享的 agent-default-model settings 分节完成。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。
|
||||
`ApiProxyService` 提供 `ctx.apiProxy`,并基于所组合的宿主上下文实现约定——会话、工作区注册表、目录选择器、agent preset、设置、凭据、LLM、事件与下载。Host cwd 是默认项目目录。网关只在 `host.describe` 报告的部署元数据中消费 `ctx.agentDefaultModel`;保存已接受的切换由 Session Controller 的 `session.selectModel` 通过共享的 agent-default-model settings 分节完成。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。
|
||||
|
||||
### 请求流
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"fflate": "^0.8.2",
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import { isUserInvocable } from '@deepseek-ai/dsh-skill'
|
||||
import {
|
||||
InvalidPresetIdError, PresetExistsError,
|
||||
@@ -19,7 +18,6 @@ import type {
|
||||
ApiProxy, ConfigurableProviderView, CredentialView,
|
||||
SettingsNamespaceView,
|
||||
} from './api/index.ts'
|
||||
import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import {
|
||||
@@ -46,25 +44,6 @@ import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
|
||||
|
||||
/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */
|
||||
const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
|
||||
|
||||
/** Validate and canonicalize one browser-supplied IANA zone at the wire boundary. */
|
||||
function canonicalClientTimeZone(value: string): string | undefined {
|
||||
if (value.length === 0 || value.trim() !== value
|
||||
|| (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined
|
||||
try {
|
||||
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value })
|
||||
.resolvedOptions().timeZone
|
||||
/* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */
|
||||
if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined
|
||||
return canonical
|
||||
} catch {
|
||||
// Intl rejects unsupported zone names; the RPC maps that parser rejection below.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Read live abort state across awaits without treating it as synchronously immutable. */
|
||||
function isAborted(signal: AbortSignal): boolean {
|
||||
return signal.aborted
|
||||
@@ -110,55 +89,6 @@ export interface ApiProxyDefaults {
|
||||
canOpenPath?: () => boolean
|
||||
}
|
||||
|
||||
/** Map continuation admission failures without exposing provider details. */
|
||||
function subagentPromptError(
|
||||
request: RpcRequest<{ childSessionId: SessionId }>,
|
||||
error: unknown,
|
||||
signal: AbortSignal,
|
||||
): RpcResponse<never> {
|
||||
const childSessionId = request.payload.childSessionId
|
||||
if (signal.aborted) {
|
||||
return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} })
|
||||
}
|
||||
if (error instanceof SubagentError) {
|
||||
switch (error.code) {
|
||||
case 'NOT_RESUMABLE':
|
||||
return err(request, {
|
||||
code: 'subagent-not-resumable',
|
||||
message: 'subagent cannot be resumed',
|
||||
details: { childSessionId },
|
||||
})
|
||||
case 'UNAUTHORIZED':
|
||||
return err(request, {
|
||||
code: 'subagent-unauthorized',
|
||||
message: 'subagent does not belong to this parent',
|
||||
details: { childSessionId },
|
||||
})
|
||||
case 'DRAINING':
|
||||
case 'ACTIVATION_CLOSING':
|
||||
case 'CONTINUATION_UNAVAILABLE':
|
||||
case 'PERSISTENCE_UNAVAILABLE':
|
||||
return err(request, {
|
||||
code: 'subagent-delivery-unavailable',
|
||||
message: 'subagent follow-up is temporarily unavailable',
|
||||
details: { childSessionId },
|
||||
})
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
|
||||
}
|
||||
|
||||
/** Stable RPC face of the missing projections capability, shared by every catalog read path. */
|
||||
function projectionsUnavailableError(): RpcError {
|
||||
return {
|
||||
code: 'internal',
|
||||
message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
details: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** The roster is absent: this deployment composes no agent presets at all. */
|
||||
function noRoster(agentPreset: string): RpcError {
|
||||
return {
|
||||
@@ -341,99 +271,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
|
||||
return {
|
||||
subagents: {
|
||||
async list(request, signal) {
|
||||
try {
|
||||
const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal)
|
||||
return ok(request, {
|
||||
entries: entries.map(entry => entry.kind === 'child'
|
||||
? {
|
||||
...entry,
|
||||
activity: ctx.agents.get(entry.id)?.status === 'running' ? 'running' : 'inactive',
|
||||
}
|
||||
: entry),
|
||||
parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'subagent catalog read was cancelled',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
|
||||
return err(request, projectionsUnavailableError())
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'subagent catalog read failed',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async prompt(request, signal) {
|
||||
const { parentSessionId, childSessionId, content, clientTimeZone } = request.payload
|
||||
const canonicalTimeZone = clientTimeZone === undefined
|
||||
? undefined
|
||||
: canonicalClientTimeZone(clientTimeZone)
|
||||
if (clientTimeZone !== undefined && canonicalTimeZone === undefined) {
|
||||
return err(request, {
|
||||
code: 'invalid-time-zone',
|
||||
message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
|
||||
details: { value: clientTimeZone },
|
||||
})
|
||||
}
|
||||
const parent = ctx.agents.get(parentSessionId)
|
||||
if (parent === undefined) {
|
||||
return err(request, {
|
||||
code: 'subagent-parent-unavailable',
|
||||
message: `parent session "${parentSessionId}" is not live`,
|
||||
details: { parentSessionId },
|
||||
})
|
||||
}
|
||||
try {
|
||||
const messageId = await ctx.subagents.followup(parent, childSessionId, content, {
|
||||
source: {
|
||||
kind: 'user',
|
||||
rpcId: request.rpcId as unknown as SessionRequestId,
|
||||
...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }),
|
||||
},
|
||||
signal,
|
||||
})
|
||||
return ok(request, { messageId })
|
||||
} catch (error: unknown) {
|
||||
return subagentPromptError(request, error, signal)
|
||||
}
|
||||
},
|
||||
|
||||
// Deliberately no catalog, history, persistence, or parent Agent lookup:
|
||||
// the core primitive alone authorizes the durable address against the
|
||||
// live Activation, which is what keeps a live child interruptible while
|
||||
// its parent Agent is offline. Absent targets are accepted no-ops there.
|
||||
interrupt(request) {
|
||||
const { parentSessionId, childSessionId } = request.payload
|
||||
try {
|
||||
ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') {
|
||||
return Promise.resolve(err(request, {
|
||||
code: 'subagent-unauthorized',
|
||||
message: 'subagent does not belong to this parent',
|
||||
details: { childSessionId },
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(err(request, {
|
||||
code: 'internal',
|
||||
message: 'subagent interrupt failed',
|
||||
details: {},
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(ok(request, { accepted: true as const }))
|
||||
},
|
||||
},
|
||||
|
||||
host: {
|
||||
describe(request) {
|
||||
// TODO(apiproxy-version): read the version from apps/cli/package.json.
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { AgentPresetsApi } from './agent-presets.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { SubagentsApi } from './subagents.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
import type { CredentialsApi } from './credentials.ts'
|
||||
import type { LlmApi } from './llm.ts'
|
||||
@@ -15,7 +14,6 @@ import type { DownloadsApi } from './downloads.ts'
|
||||
|
||||
/** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */
|
||||
export interface ApiProxy {
|
||||
subagents: SubagentsApi
|
||||
host: HostApi
|
||||
skills: SkillsApi
|
||||
agentPresets: AgentPresetsApi
|
||||
@@ -32,10 +30,6 @@ export type {
|
||||
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 { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { AgentPresetsApi } from './agent-presets.ts'
|
||||
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { SkillsApi } from './skills.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
import type { CredentialsApi } from './credentials.ts'
|
||||
import type { LlmApi } from './llm.ts'
|
||||
import type { SubagentsApi } from './subagents.ts'
|
||||
import type { RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
@@ -18,9 +17,6 @@ import type { RpcResponse } from './rpc.ts'
|
||||
* request; the carrier passes its request signal, never a wire field.
|
||||
*/
|
||||
export interface RpcMethodMap {
|
||||
'subagent.list': SubagentsApi['list']
|
||||
'subagent.prompt': SubagentsApi['prompt']
|
||||
'subagent.interrupt': SubagentsApi['interrupt']
|
||||
'host.describe': HostApi['describe']
|
||||
'host.pickDirectory': HostApi['pickDirectory']
|
||||
'host.listDirectory': HostApi['listDirectory']
|
||||
|
||||
@@ -49,16 +49,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
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('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({
|
||||
parentSessionId: z.string(),
|
||||
childSessionId: z.string(),
|
||||
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
|
||||
}) }),
|
||||
z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-delivery-unavailable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||
]) as unknown as z.ZodType<RpcError>
|
||||
|
||||
|
||||
@@ -62,16 +62,6 @@ export interface RpcErrorDetailsMap {
|
||||
* details name the endpoint asked, never the credential offered.
|
||||
*/
|
||||
'model-discovery-failed': { settingsNs: string; baseURL?: string }
|
||||
'subagent-parent-unavailable': { parentSessionId: SessionId }
|
||||
'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId }
|
||||
'subagent-catalog-diagnostic': {
|
||||
parentSessionId: SessionId
|
||||
childSessionId: SessionId
|
||||
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
'subagent-not-resumable': { childSessionId: SessionId }
|
||||
'subagent-unauthorized': { childSessionId: SessionId }
|
||||
'subagent-delivery-unavailable': { childSessionId: SessionId }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/** Zod schemas for the browser-safe subagent domain. */
|
||||
|
||||
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 { 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({
|
||||
kind: z.literal('child'),
|
||||
id: sessionIdSchema,
|
||||
mode: z.literal('one-shot'),
|
||||
activity: z.union([z.literal('running'), z.literal('inactive')]),
|
||||
hasChildren: z.boolean(),
|
||||
label: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('child'),
|
||||
id: sessionIdSchema,
|
||||
mode: z.literal('continuable'),
|
||||
activity: z.union([z.literal('running'), z.literal('inactive')]),
|
||||
hasChildren: z.boolean(),
|
||||
label: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('diagnostic'),
|
||||
id: sessionIdSchema,
|
||||
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
|
||||
}),
|
||||
]) satisfies z.ZodType<Wire<SubagentListEntry>>
|
||||
|
||||
/** subagent.list request payload. */
|
||||
export const subagentListRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.list'>>>
|
||||
|
||||
/** subagent.list response value. */
|
||||
export const subagentListValueSchema = z.object({
|
||||
entries: z.array(subagentListEntrySchema),
|
||||
parentAvailable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.list'>>>
|
||||
|
||||
/** subagent.prompt request payload. */
|
||||
export const subagentPromptRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
childSessionId: sessionIdSchema,
|
||||
mode: z.literal('continuable'),
|
||||
content: z.array(contentBlockSchema),
|
||||
clientTimeZone: z.string().optional(),
|
||||
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
|
||||
|
||||
/** subagent.interrupt request payload. */
|
||||
export const subagentInterruptRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
childSessionId: sessionIdSchema,
|
||||
mode: z.literal('continuable'),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.interrupt'>>>
|
||||
|
||||
/** subagent.interrupt response value. */
|
||||
export const subagentInterruptValueSchema = z.object({
|
||||
accepted: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.interrupt'>>>
|
||||
|
||||
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
|
||||
|
||||
/** subagent.prompt response value. */
|
||||
export const subagentPromptValueSchema = z.object({
|
||||
messageId: messageIdSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.prompt'>>>
|
||||
@@ -1,101 +0,0 @@
|
||||
/** 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'
|
||||
|
||||
/** Complete durable direct-child catalog row. */
|
||||
export type SubagentListEntry =
|
||||
| {
|
||||
kind: 'child'
|
||||
id: SessionId
|
||||
/** Whether the child Agent driver is running at the Host sampling boundary. */
|
||||
activity: 'running' | 'inactive'
|
||||
/** Whether a direct descendant has durable `origin: 'subagent'`. */
|
||||
hasChildren: boolean
|
||||
} & (
|
||||
| {
|
||||
mode: 'one-shot'
|
||||
label?: string
|
||||
}
|
||||
| {
|
||||
mode: 'continuable'
|
||||
label: string
|
||||
}
|
||||
)
|
||||
| {
|
||||
kind: 'diagnostic'
|
||||
id: SessionId
|
||||
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/** Inbox identity returned once the continuation accepts one human message. */
|
||||
export interface SubagentPromptReceipt {
|
||||
messageId: MessageId
|
||||
}
|
||||
|
||||
/** Uniform acknowledgement that one interrupt request was admitted. */
|
||||
export interface SubagentInterruptReceipt {
|
||||
accepted: true
|
||||
}
|
||||
|
||||
/** Durable parent/child address that selects subagent transport in the client. */
|
||||
export type SubagentAddress =
|
||||
& {
|
||||
parentSessionId: SessionId
|
||||
childSessionId: SessionId
|
||||
}
|
||||
& (
|
||||
| { mode: 'one-shot' }
|
||||
| { mode: 'continuable' }
|
||||
)
|
||||
|
||||
/** Complete direct-child catalog plus the delivery-time parent availability hint. */
|
||||
export interface SubagentCatalog {
|
||||
entries: SubagentListEntry[]
|
||||
parentAvailable: boolean
|
||||
}
|
||||
|
||||
/** Subagent-domain unary methods. */
|
||||
export interface SubagentsApi {
|
||||
/**
|
||||
* Lists direct session-backed children without loading either side. Parent
|
||||
* availability is a hint; continuable prompt performs the authoritative
|
||||
* check.
|
||||
*/
|
||||
list(
|
||||
request: RpcRequest<{ parentSessionId: SessionId }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<SubagentCatalog>>
|
||||
|
||||
/**
|
||||
* Delivers human content to a continuable child through the exact live
|
||||
* parent's continuation owner. Success identifies the message accepted by
|
||||
* the child's FIFO inbox; later execution is independent of this request.
|
||||
* Optional browser-zone provenance is validated and logged on that message.
|
||||
*/
|
||||
prompt(
|
||||
request: RpcRequest<
|
||||
Extract<SubagentAddress, { mode: 'continuable' }> & {
|
||||
content: ContentBlock[]
|
||||
/** Optional browser zone sampled for this exact human prompt. */
|
||||
clientTimeZone?: string
|
||||
}
|
||||
>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<SubagentPromptReceipt>>
|
||||
|
||||
/**
|
||||
* Interrupts a live continuable child's current turn under the address's
|
||||
* durable direct-parent authority, without requiring a live parent Agent,
|
||||
* consulting the catalog, or resuming anything. Fire-and-return: `accepted`
|
||||
* acknowledges the admitted cancel signal, not target quiescence, so the
|
||||
* child may remain visibly running briefly. Unclaimed queued follow-ups are
|
||||
* kept and parked; an absent, idle, or already-completed target is likewise
|
||||
* `accepted`.
|
||||
*/
|
||||
interrupt(
|
||||
request: RpcRequest<Extract<SubagentAddress, { mode: 'continuable' }>>,
|
||||
): Promise<RpcResponse<SubagentInterruptReceipt>>
|
||||
}
|
||||
@@ -28,11 +28,6 @@ import {
|
||||
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
|
||||
} from '../api/credentials.schema.ts'
|
||||
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
|
||||
import {
|
||||
subagentInterruptValueSchema,
|
||||
subagentListValueSchema,
|
||||
subagentPromptValueSchema,
|
||||
} from '../api/subagents.schema.ts'
|
||||
|
||||
/**
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
@@ -47,11 +42,6 @@ import {
|
||||
* Derived per method key from RpcMethodMap so a map row addition updates this mechanically.
|
||||
*/
|
||||
export interface IApiClient {
|
||||
subagents: {
|
||||
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
|
||||
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
|
||||
interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.interrupt'>>>
|
||||
}
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
|
||||
@@ -89,9 +79,6 @@ 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>>> } = {
|
||||
'subagent.list': subagentListValueSchema,
|
||||
'subagent.prompt': subagentPromptValueSchema,
|
||||
'subagent.interrupt': subagentInterruptValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'host.pickDirectory': hostPickDirectoryValueSchema,
|
||||
'host.listDirectory': hostListDirectoryValueSchema,
|
||||
@@ -240,12 +227,6 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
// ---- IApiClient API (arrow properties so destructured/passed references stay bound) ----
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
|
||||
interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
|
||||
// A native system dialog is user-paced and may legitimately stay open
|
||||
|
||||
@@ -31,11 +31,6 @@ import {
|
||||
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
|
||||
} from '../api/credentials.schema.ts'
|
||||
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
|
||||
import {
|
||||
subagentInterruptRequestSchema,
|
||||
subagentListRequestSchema,
|
||||
subagentPromptRequestSchema,
|
||||
} from '../api/subagents.schema.ts'
|
||||
|
||||
/**
|
||||
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
||||
@@ -54,9 +49,6 @@ type UnaryRoutes = {
|
||||
}
|
||||
|
||||
const UNARY_ROUTES: UnaryRoutes = {
|
||||
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(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) },
|
||||
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
|
||||
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface Config {
|
||||
*/
|
||||
export class ApiProxyService extends Service implements ApiProxy {
|
||||
static inject = [
|
||||
'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
|
||||
'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'sessionQuery',
|
||||
'sessionController',
|
||||
]
|
||||
|
||||
@@ -72,7 +72,6 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
|
||||
})
|
||||
|
||||
readonly subagents: ApiProxy['subagents']
|
||||
readonly host: ApiProxy['host']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly agentPresets: ApiProxy['agentPresets']
|
||||
@@ -91,7 +90,6 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
? {}
|
||||
: { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
|
||||
})
|
||||
this.subagents = api.subagents
|
||||
this.host = api.host
|
||||
this.skills = api.skills
|
||||
this.agentPresets = api.agentPresets
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import type { RpcRequest } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
const PARENT = sid('parent')
|
||||
const CHILD = sid('child')
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId('subagent-rpc'), payload }
|
||||
}
|
||||
|
||||
function bench(options: {
|
||||
parentLive?: boolean
|
||||
childStatus?: 'idle' | 'running'
|
||||
entries?: object[]
|
||||
followupError?: Error
|
||||
interruptError?: Error
|
||||
listError?: Error
|
||||
} = {}) {
|
||||
const parent = { id: PARENT }
|
||||
const child = options.childStatus === undefined
|
||||
? undefined
|
||||
: { id: CHILD, status: options.childStatus }
|
||||
const getAgent = vi.fn((id: SessionId) => {
|
||||
if (options.parentLive !== false && id === PARENT) return parent
|
||||
if (id === CHILD) return child
|
||||
return undefined
|
||||
})
|
||||
const listChildren = vi.fn(() => options.listError === undefined
|
||||
? Promise.resolve(options.entries ?? [
|
||||
{
|
||||
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
: Promise.reject(options.listError))
|
||||
const followup = vi.fn((
|
||||
_parent: unknown,
|
||||
_childId: SessionId,
|
||||
_content: unknown,
|
||||
_delivery: {
|
||||
source: { kind: string; rpcId: RpcId; clientTimeZone?: string }
|
||||
signal: AbortSignal
|
||||
},
|
||||
) => options.followupError === undefined
|
||||
? Promise.resolve('message-1')
|
||||
: Promise.reject(options.followupError))
|
||||
const interrupt = vi.fn((
|
||||
_targetSessionId: SessionId,
|
||||
_authority: { kind: 'user'; parentSessionId: SessionId },
|
||||
) => {
|
||||
if (options.interruptError !== undefined) throw options.interruptError
|
||||
})
|
||||
const ctx = new Context()
|
||||
ctx.provide('agents', { get: getAgent })
|
||||
ctx.provide('subagents', { listChildren, followup, interrupt })
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
|
||||
})
|
||||
return { api, getAgent, listChildren, followup, interrupt, parent }
|
||||
}
|
||||
|
||||
describe('subagent gateway', () => {
|
||||
it('lists the complete catalog and reports exact live-parent availability', async () => {
|
||||
const { api, listChildren } = bench({ parentLive: false, entries: [
|
||||
{
|
||||
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
|
||||
activity: 'inactive', hasChildren: true,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: sid('one-shot'), mode: 'one-shot',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
{ kind: 'diagnostic', id: sid('bad'), reason: 'corrupt' },
|
||||
] })
|
||||
const response = await api.subagents.list(request({ parentSessionId: PARENT }))
|
||||
expect(response.rpcId).toBe('subagent-rpc')
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
parentAvailable: false,
|
||||
entries: [
|
||||
{ kind: 'child', mode: 'continuable' },
|
||||
{ kind: 'child', mode: 'one-shot' },
|
||||
{ kind: 'diagnostic' },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(listChildren).toHaveBeenCalledWith(PARENT, undefined)
|
||||
})
|
||||
|
||||
it('derives catalog activity from the live child Agent rather than Session residency', async () => {
|
||||
const residentIdle = bench({ childStatus: 'idle', entries: [{
|
||||
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}] })
|
||||
expect((await residentIdle.api.subagents.list(request({ parentSessionId: PARENT }))).result)
|
||||
.toMatchObject({ ok: true, value: { entries: [{ activity: 'inactive' }] } })
|
||||
|
||||
const running = bench({ childStatus: 'running' })
|
||||
expect((await running.api.subagents.list(request({ parentSessionId: PARENT }))).result)
|
||||
.toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } })
|
||||
})
|
||||
|
||||
it('maps missing catalog projections on list without preflighting prompt delivery', async () => {
|
||||
const listError = () => new SubagentError(
|
||||
'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
|
||||
)
|
||||
const expected = {
|
||||
code: 'internal',
|
||||
message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
}
|
||||
|
||||
const list = bench({ listError: listError() })
|
||||
expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result)
|
||||
.toMatchObject({ ok: false, error: expected })
|
||||
|
||||
const prompt = bench({ listError: listError() })
|
||||
expect((await prompt.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
|
||||
}), new AbortController().signal)).result).toMatchObject({ ok: true })
|
||||
expect(prompt.listChildren).not.toHaveBeenCalled()
|
||||
expect(prompt.followup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('routes human content through the exact live parent with rpc attribution', async () => {
|
||||
const { api, parent, followup } = bench()
|
||||
const content = [{ type: 'text' as const, text: '继续' }]
|
||||
const signal = new AbortController().signal
|
||||
const response = await api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content,
|
||||
}), signal)
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true, value: { messageId: 'message-1' },
|
||||
})
|
||||
expect(followup).toHaveBeenCalledWith(
|
||||
parent,
|
||||
CHILD,
|
||||
content,
|
||||
{ source: { kind: 'user', rpcId: RpcId('subagent-rpc') }, signal },
|
||||
)
|
||||
})
|
||||
|
||||
it('canonicalizes browser-zone provenance before delivering a child prompt', async () => {
|
||||
const { api, parent, followup } = bench()
|
||||
const alias = 'US/Pacific'
|
||||
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
|
||||
.resolvedOptions().timeZone
|
||||
const content = [{ type: 'text' as const, text: 'continue locally' }]
|
||||
const signal = new AbortController().signal
|
||||
await expect(api.subagents.prompt(request({
|
||||
parentSessionId: PARENT,
|
||||
childSessionId: CHILD,
|
||||
mode: 'continuable',
|
||||
content,
|
||||
clientTimeZone: alias,
|
||||
}), signal)).resolves.toMatchObject({ result: { ok: true } })
|
||||
expect(followup).toHaveBeenCalledWith(parent, CHILD, content, {
|
||||
source: { kind: 'user', rpcId: RpcId('subagent-rpc'), clientTimeZone: canonical },
|
||||
signal,
|
||||
})
|
||||
|
||||
const invalid = await api.subagents.prompt(request({
|
||||
parentSessionId: PARENT,
|
||||
childSessionId: CHILD,
|
||||
mode: 'continuable',
|
||||
content,
|
||||
clientTimeZone: 'Not/A_Real_Zone',
|
||||
}), signal)
|
||||
expect(invalid.result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid-time-zone',
|
||||
message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
|
||||
details: { value: 'Not/A_Real_Zone' },
|
||||
},
|
||||
})
|
||||
expect(followup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails before delivery when the parent is absent and maps continuation failures', async () => {
|
||||
const absent = bench({ parentLive: false })
|
||||
expect((await absent.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
|
||||
}), new AbortController().signal)).result).toMatchObject({
|
||||
ok: false, error: { code: 'subagent-parent-unavailable' },
|
||||
})
|
||||
expect(absent.listChildren).not.toHaveBeenCalled()
|
||||
|
||||
const failed = bench({ followupError: new SubagentError('draining', 'DRAINING') })
|
||||
expect((await failed.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
|
||||
}), new AbortController().signal)).result).toMatchObject({
|
||||
ok: false, error: { code: 'subagent-delivery-unavailable' },
|
||||
})
|
||||
})
|
||||
|
||||
it('hides unexpected backend details', async () => {
|
||||
const catalog = bench({ listError: new Error('secret descriptor') })
|
||||
expect((await catalog.api.subagents.list(request({
|
||||
parentSessionId: PARENT,
|
||||
}))).result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'subagent catalog read failed' },
|
||||
})
|
||||
|
||||
const prompt = bench({ followupError: new Error('secret provider') })
|
||||
expect((await prompt.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
|
||||
}), new AbortController().signal)).result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'subagent prompt failed' },
|
||||
})
|
||||
})
|
||||
|
||||
it('interrupts through the core primitive alone while the parent Agent is offline', async () => {
|
||||
const { api, interrupt, getAgent, listChildren } = bench({ parentLive: false })
|
||||
const response = await api.subagents.interrupt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
|
||||
}))
|
||||
expect(response.rpcId).toBe('subagent-rpc')
|
||||
expect(response.result).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT })
|
||||
// No parent-registry or catalog dependency: this is what keeps a
|
||||
// live child interruptible after its parent Agent went offline.
|
||||
expect(getAgent).not.toHaveBeenCalled()
|
||||
expect(listChildren).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps interrupt authorization rejection without touching other services', async () => {
|
||||
const { api, listChildren } = bench({
|
||||
interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'),
|
||||
})
|
||||
const response = await api.subagents.interrupt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
|
||||
}))
|
||||
expect(response.result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-unauthorized',
|
||||
message: 'subagent does not belong to this parent',
|
||||
details: { childSessionId: CHILD },
|
||||
},
|
||||
})
|
||||
expect(listChildren).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides unexpected interrupt failures behind the internal code', async () => {
|
||||
const { api } = bench({ interruptError: new Error('secret activation state') })
|
||||
const response = await api.subagents.interrupt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
|
||||
}))
|
||||
expect(response.result).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'subagent interrupt failed', details: {} },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,19 +6,15 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ApiProxy, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>> {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
|
||||
}
|
||||
|
||||
/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
|
||||
function scriptedApi(overrides: {
|
||||
subagents?: Partial<ApiProxy['subagents']>
|
||||
host?: Partial<ApiProxy['host']>
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
agentPresets?: Partial<ApiProxy['agentPresets']>
|
||||
@@ -29,12 +25,6 @@ function scriptedApi(overrides: {
|
||||
const err = <T>(r: RpcRequest<unknown>): Promise<RpcResponse<T>> =>
|
||||
Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } })
|
||||
return {
|
||||
subagents: {
|
||||
list: r => ok(r, { entries: [], parentAvailable: false }),
|
||||
prompt: r => ok(r, { messageId: 'message-1' as never }),
|
||||
interrupt: r => ok(r, { accepted: true as const }),
|
||||
...overrides.subagents,
|
||||
},
|
||||
host: {
|
||||
describe: r => ok(r, {
|
||||
version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true,
|
||||
@@ -140,32 +130,6 @@ describe('unary round trip', () => {
|
||||
await expect(client(api).host.describe({})).rejects.toThrow(/rpcId mismatch/)
|
||||
})
|
||||
|
||||
it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => {
|
||||
const interrupt = vi.fn((r: RpcRequest<unknown>) => ok(r, { accepted: true as const }))
|
||||
const api = scriptedApi({ subagents: { interrupt } })
|
||||
const c = client(api)
|
||||
|
||||
const accepted = await c.subagents.interrupt({
|
||||
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable',
|
||||
})
|
||||
expect(accepted.result).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(interrupt).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The wire schema owns the mode fence: a one-shot address never reaches the impl.
|
||||
const oneShot = await c.subagents.interrupt({
|
||||
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot',
|
||||
} as never)
|
||||
expect(oneShot.result.ok).toBe(false)
|
||||
if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request')
|
||||
|
||||
const incomplete = await c.subagents.interrupt({
|
||||
parentSessionId: sid('parent'), mode: 'continuable',
|
||||
} as never)
|
||||
expect(incomplete.result.ok).toBe(false)
|
||||
if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request')
|
||||
expect(interrupt).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a method/path mismatch as bad-request', async () => {
|
||||
const handler = toFetchHandler(scriptedApi())
|
||||
const body = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} }
|
||||
|
||||
@@ -7,31 +7,6 @@ import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts'
|
||||
/** Minimal in-memory ApiProxy that echoes rpcIds. */
|
||||
function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy {
|
||||
return {
|
||||
subagents: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { entries: [], parentAvailable: false } } }
|
||||
},
|
||||
async prompt(request, signal) {
|
||||
if (request.payload.content.some(block => block.type === 'text' && block.text === 'hang')) {
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: false, error: { code: 'cancelled' as const, message: 'aborted', details: {} } },
|
||||
}
|
||||
}
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { messageId: 'message-1' as never } },
|
||||
}
|
||||
},
|
||||
async interrupt(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
},
|
||||
host: {
|
||||
async describe(request) {
|
||||
if (overrides.crashOn === 'host.describe') throw new Error('impl crashed')
|
||||
@@ -221,23 +196,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips the subagent domain through the wire form', async () => {
|
||||
const c = client()
|
||||
expect((await c.subagents.list({ parentSessionId: 'parent' as never })).result)
|
||||
.toEqual({ ok: true, value: { entries: [], parentAvailable: false } })
|
||||
expect((await c.subagents.prompt({
|
||||
parentSessionId: 'parent' as never,
|
||||
childSessionId: 'child' as never,
|
||||
mode: 'continuable',
|
||||
content: [],
|
||||
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
|
||||
expect((await c.subagents.interrupt({
|
||||
parentSessionId: 'parent' as never,
|
||||
childSessionId: 'child' as never,
|
||||
mode: 'continuable',
|
||||
})).result).toEqual({ ok: true, value: { accepted: true } })
|
||||
})
|
||||
|
||||
it('keeps caller and connection aborts on a deadline-exempt unary', async () => {
|
||||
const api = fakeApi()
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
@@ -263,34 +221,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
expect(handlerSignal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into subagent.prompt', async () => {
|
||||
const handler = toFetchHandler(fakeApi())
|
||||
const controller = new AbortController()
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'r-subagent-sig',
|
||||
method: 'subagent.prompt',
|
||||
payload: {
|
||||
parentSessionId: 'parent',
|
||||
childSessionId: 'child',
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: 'hang' }],
|
||||
},
|
||||
})
|
||||
const pending = handler.fetch(new Request(
|
||||
'http://x/api/subagent.prompt',
|
||||
{ method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal },
|
||||
))
|
||||
controller.abort()
|
||||
const response = await pending
|
||||
const parsed = await response.json() as {
|
||||
rpcId: string
|
||||
result: { error?: { code: string } }
|
||||
}
|
||||
expect(parsed.rpcId).toBe('r-subagent-sig')
|
||||
expect(parsed.result.error?.code).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('propagates the carrier Request signal into host.pickDirectory', async () => {
|
||||
const api = fakeApi()
|
||||
api.host.pickDirectory = async (request, signal) => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '../src/api/host.schema.ts'
|
||||
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
|
||||
import { agentPresetOpenDocumentValueSchema } from '../src/api/agent-presets.schema.ts'
|
||||
import { subagentPromptRequestSchema } from '../src/api/subagents.schema.ts'
|
||||
|
||||
describe('RpcId', () => {
|
||||
it('brands a raw string at zero runtime cost', () => {
|
||||
@@ -51,12 +50,6 @@ describe('rpcErrorSchema', () => {
|
||||
// The credentials producer still emits this code, so the branch has to stay.
|
||||
expect(rpcErrorSchema.parse({ code: 'credential-rejected', message: 'm', details: { ref: 'r' } }).code).toBe('credential-rejected')
|
||||
expect(rpcErrorSchema.parse({ code: 'model-discovery-failed', message: 'm', details: { settingsNs: 'n' } }).code).toBe('model-discovery-failed')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-parent-unavailable', message: 'm', details: { parentSessionId: 'p' } }).code).toBe('subagent-parent-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-not-found', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c' } }).code).toBe('subagent-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-catalog-diagnostic', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c', reason: 'corrupt' } }).code).toBe('subagent-catalog-diagnostic')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-not-resumable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-resumable')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-unauthorized', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-unauthorized')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-delivery-unavailable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-delivery-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
||||
})
|
||||
|
||||
@@ -99,24 +92,6 @@ describe('wire full-form schemas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent domain schemas', () => {
|
||||
it('carries optional request-local browser-zone provenance on prompts', () => {
|
||||
expect(subagentPromptRequestSchema.parse({
|
||||
parentSessionId: 'parent',
|
||||
childSessionId: 'child',
|
||||
mode: 'continuable',
|
||||
content: [{ type: 'text', text: 'continue' }],
|
||||
clientTimeZone: 'Asia/Shanghai',
|
||||
}).clientTimeZone).toBe('Asia/Shanghai')
|
||||
expect(subagentPromptRequestSchema.parse({
|
||||
parentSessionId: 'parent',
|
||||
childSessionId: 'child',
|
||||
mode: 'continuable',
|
||||
content: [],
|
||||
}).clientTimeZone).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('host domain schemas', () => {
|
||||
it('validates describe request/value', () => {
|
||||
expect(hostDescribeRequestSchema.parse({})).toEqual({})
|
||||
|
||||
@@ -53,9 +53,6 @@
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
|
||||
@@ -196,13 +196,16 @@ describe('SessionProjectionCache write policy', () => {
|
||||
})
|
||||
|
||||
it('flushes on the configured interval when the count threshold is not reached', async () => {
|
||||
const { ctx, root } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 20 } })
|
||||
const { ctx, cache } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 20 } })
|
||||
const write = vi.spyOn(cache, 'write').mockResolvedValue()
|
||||
vi.useFakeTimers()
|
||||
const session = ctx.sessions.create(SessionId('interval'))
|
||||
write.mockClear()
|
||||
mark(session, ['slow'])
|
||||
await new Promise(resolve => setTimeout(resolve, 10)) // before the interval
|
||||
expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) // still the creation cut
|
||||
await settle() // past the interval; the fire-and-forget write lands
|
||||
expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
|
||||
await vi.advanceTimersByTimeAsync(19)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(write).toHaveBeenCalledExactlyOnceWith(session)
|
||||
})
|
||||
|
||||
it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
|
||||
README.md: cbc6f3c2cc00a1d71e917f27b6b2b145630cefc3
|
||||
README.zh.md: 91ad0cc787c84a98c2f5080c33b2a10e2f96dc24
|
||||
README.md: 76df70351d711d580d3ab1a89d0f929b85eab172
|
||||
README.zh.md: c4deb001c47435d29a5ca18cc0f8c0d26e48941e
|
||||
|
||||
@@ -81,6 +81,8 @@ This section explains how the service is built and where the observable behavior
|
||||
| [`src/descriptor.ts`](src/descriptor.ts) | Versioned `subagent/descriptor` session-event vocabulary |
|
||||
| [`src/child-agent.ts`](src/child-agent.ts) | Child composition, delegated policy, depth helpers |
|
||||
| [`src/list-children.ts`](src/list-children.ts) | Discovery over the live session store and optional persistence |
|
||||
| [`src/control.ts`](src/control.ts) | Browser control assembly: catalog activity sampling, browser-zone validation, failure codes |
|
||||
| [`src/control-types.ts`](src/control-types.ts) | Client-safe catalog row, control requests, receipts, and failures |
|
||||
|
||||
### One-shot flow
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ kind: "package-reference"
|
||||
| [`src/descriptor.ts`](src/descriptor.ts) | 版本化的 `subagent/descriptor` 会话事件词汇 |
|
||||
| [`src/child-agent.ts`](src/child-agent.ts) | 子级组装、委派策略、深度辅助函数 |
|
||||
| [`src/list-children.ts`](src/list-children.ts) | 基于在线会话存储与可选持久化的发现 |
|
||||
| [`src/control.ts`](src/control.ts) | 浏览器控制面组装:目录活性采样、浏览器时区校验、失败分码 |
|
||||
| [`src/control-types.ts`](src/control-types.ts) | client-safe 的目录行、控制面请求、回执与失败 |
|
||||
|
||||
### 一次性流程
|
||||
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
"types": "./lib/types/client.d.ts",
|
||||
"default": "./lib/types/client.js"
|
||||
},
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
},
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -33,7 +41,11 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -56,6 +68,7 @@
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
@@ -108,6 +121,7 @@
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Browser-safe subagent projection vocabulary.
|
||||
* Browser-safe subagent projection and control vocabulary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/client
|
||||
*/
|
||||
|
||||
export type * from './control-types.ts'
|
||||
export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Client-safe subagent catalog and control vocabulary: the durable direct-child
|
||||
* row both the listing and the browser catalog answer with, plus the
|
||||
* browser-facing control surface's prompt, receipts, and failures.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/control-types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
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 { z as zCore } from 'zod'
|
||||
|
||||
/**
|
||||
* Client-minted identity of one browser prompt, persisted on the exact accepted
|
||||
* message. It carries the Session Controller's `session-request-id` brand so a
|
||||
* subagent prompt and an ordinary Session prompt share one identity
|
||||
* vocabulary; that package depends on this one, so the brand is spelled here
|
||||
* rather than imported.
|
||||
*/
|
||||
export type SubagentPromptRequestId = Branded<'session-request-id'>
|
||||
|
||||
/**
|
||||
* One durable direct-child row, ordered by header `createdAt` with ties broken
|
||||
* on id. Only a candidate whose durable header has `origin: 'subagent'` is
|
||||
* interpreted. A served `subagent` projection value produces a `child`; a
|
||||
* settled candidate whose fold served no identity produces a `diagnostic`; a
|
||||
* running candidate without one is omitted — its descriptor may not be
|
||||
* appended yet (the creation window). Diagnostics relay the projection fold's
|
||||
* outcome or a failed read, never a per-child event scan, and never expose
|
||||
* model-hidden descriptor content.
|
||||
*/
|
||||
export type SubagentListEntry =
|
||||
| {
|
||||
readonly kind: 'child'
|
||||
/** The durable child session id, stable across Activations. */
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* Whether the child is live at the moment its reader sampled it: the
|
||||
* durable listing reads the Session store (`running` means the logical
|
||||
* record is resident, `inactive` that it exists only in persistence),
|
||||
* while the browser catalog re-samples the child's Agent driver. Neither
|
||||
* encodes a durable outcome, and a continuable child may still reject
|
||||
* delivery as an ownership conflict.
|
||||
*/
|
||||
readonly activity: 'running' | 'inactive'
|
||||
/** Whether a direct descendant has durable `origin: 'subagent'`. */
|
||||
readonly hasChildren: boolean
|
||||
} & (
|
||||
| {
|
||||
/** A terminal one-shot child. */
|
||||
readonly mode: 'one-shot'
|
||||
/** Optional durable creation label from the child's descriptor. */
|
||||
readonly label?: string
|
||||
}
|
||||
| {
|
||||
/** A resumable conversation. */
|
||||
readonly mode: 'continuable'
|
||||
/** Durable creation label from the child's descriptor. */
|
||||
readonly label: string
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly kind: 'diagnostic'
|
||||
/** The candidate's session id. */
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* Why the candidate has no `child` row: `corrupt` for a settled candidate
|
||||
* whose projection fold served no identity (a missing, malformed, or
|
||||
* unrecognized-version descriptor — deliberately undistinguished), and
|
||||
* for any candidate whose log makes a registered unit's fold or schema
|
||||
* throw (deterministic data damage, contained per child); `unavailable`
|
||||
* when the candidate's Session observation was absent or transiently
|
||||
* unreadable (retried on the next listing). `unsupported` is never produced; it remains in the
|
||||
* union for consumers that route on it.
|
||||
*/
|
||||
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/** Complete direct-child catalog plus the delivery-time parent availability hint. */
|
||||
export interface SubagentCatalog {
|
||||
readonly entries: readonly SubagentListEntry[]
|
||||
readonly parentAvailable: boolean
|
||||
}
|
||||
|
||||
/** Durable parent/child address that selects subagent transport in the client. */
|
||||
export type SubagentAddress =
|
||||
& {
|
||||
readonly parentSessionId: SessionId
|
||||
readonly childSessionId: SessionId
|
||||
}
|
||||
& (
|
||||
| { readonly mode: 'one-shot' }
|
||||
| { readonly mode: 'continuable' }
|
||||
)
|
||||
|
||||
/** One human message addressed to a continuable direct child. */
|
||||
export interface SubagentPromptRequest {
|
||||
/** Identity persisted on the accepted message, minted before the call. */
|
||||
readonly requestId: SubagentPromptRequestId
|
||||
readonly parentSessionId: SessionId
|
||||
readonly childSessionId: SessionId
|
||||
/** Required discriminator retained from the browser control address. */
|
||||
readonly mode: 'continuable'
|
||||
/** Content delivered as the child's user message. */
|
||||
readonly content: ContentBlock[]
|
||||
/** Optional browser zone sampled for this exact human prompt. */
|
||||
readonly clientTimeZone?: string
|
||||
}
|
||||
|
||||
/** Inbox identity returned once the continuation accepts one human message. */
|
||||
export interface SubagentPromptReceipt {
|
||||
readonly messageId: MessageId
|
||||
}
|
||||
|
||||
/** Uniform acknowledgement that one interrupt request was admitted. */
|
||||
export interface SubagentInterruptReceipt {
|
||||
readonly accepted: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure details the control surface answers with. The catalog read, the
|
||||
* prompt, and the interrupt produce these codes; a Client fabricates
|
||||
* `subagent-not-resumable` and `subagent-delivery-unavailable` for a one-shot
|
||||
* address it refuses before the call, so both planes read one vocabulary.
|
||||
*/
|
||||
export interface SubagentControlErrorDetailsMap {
|
||||
'bad-request': { readonly issues: zCore.core.$ZodIssue[] }
|
||||
cancelled: Record<never, never>
|
||||
'invalid-time-zone': { readonly value: string }
|
||||
'subagent-parent-unavailable': { readonly parentSessionId: SessionId }
|
||||
'subagent-not-resumable': { readonly childSessionId: SessionId }
|
||||
'subagent-unauthorized': { readonly childSessionId: SessionId }
|
||||
'subagent-delivery-unavailable': { readonly childSessionId: SessionId }
|
||||
'subagent-projections-unavailable': Record<never, never>
|
||||
internal: Record<never, never>
|
||||
}
|
||||
|
||||
/** One subagent control failure, returned without a carrier error. */
|
||||
export type SubagentControlError = {
|
||||
[Code in keyof SubagentControlErrorDetailsMap]: {
|
||||
readonly code: Code
|
||||
readonly message: string
|
||||
readonly details: SubagentControlErrorDetailsMap[Code]
|
||||
}
|
||||
}[keyof SubagentControlErrorDetailsMap]
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Browser-facing subagent control assembly: the catalog view sampled against
|
||||
* the live Agent registry, one browser zone's validation, and the stable
|
||||
* failure codes the Remote surface answers with.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
SubagentCatalog, SubagentControlErrorDetailsMap, SubagentListEntry,
|
||||
} from './control-types.ts'
|
||||
import { SubagentError } from './error.ts'
|
||||
|
||||
/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */
|
||||
const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
|
||||
|
||||
const SESSION_ID_SCHEMA = z.string().min(1)
|
||||
const CONTROL_ID_SCHEMAS = {
|
||||
'subagent.list': z.object({ parentSessionId: SESSION_ID_SCHEMA }),
|
||||
'subagent.prompt': z.object({
|
||||
parentSessionId: SESSION_ID_SCHEMA,
|
||||
childSessionId: SESSION_ID_SCHEMA,
|
||||
mode: z.literal('continuable'),
|
||||
}),
|
||||
'subagent.interrupt': z.object({
|
||||
parentSessionId: SESSION_ID_SCHEMA,
|
||||
childSessionId: SESSION_ID_SCHEMA,
|
||||
mode: z.literal('continuable'),
|
||||
}),
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Validate and canonicalize one browser-supplied IANA zone at the wire boundary.
|
||||
* @param value - the browser's reported zone name.
|
||||
* @returns the canonical zone, or `undefined` when the name is unusable.
|
||||
*/
|
||||
export function canonicalClientTimeZone(value: string): string | undefined {
|
||||
if (value.length === 0 || value.trim() !== value
|
||||
|| (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined
|
||||
try {
|
||||
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value })
|
||||
.resolvedOptions().timeZone
|
||||
/* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */
|
||||
if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined
|
||||
return canonical
|
||||
} catch {
|
||||
// Intl rejects unsupported zone names; the caller maps that parser rejection.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse one Remote call with a stable business failure the carrier preserves.
|
||||
* @param code - declared caller-facing code.
|
||||
* @param message - human-readable refusal.
|
||||
* @param details - that code's declared detail payload.
|
||||
* @returns Never — the failure is thrown.
|
||||
* @throws {TypertRemoteFailure} always.
|
||||
*/
|
||||
export function rejectControl<Code extends keyof SubagentControlErrorDetailsMap>(
|
||||
code: Code,
|
||||
message: string,
|
||||
details: SubagentControlErrorDetailsMap[Code],
|
||||
): never {
|
||||
throw new TypertRemoteFailure({ code, message, details })
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the subagent payload checks that are stricter than generated
|
||||
* branded-string codecs.
|
||||
* @param method - method name carried in the failure message.
|
||||
* @param payload - decoded control fields to validate.
|
||||
* @throws {TypertRemoteFailure} `bad-request` with the original Zod issues.
|
||||
*/
|
||||
export function validateControlRequest(
|
||||
method: keyof typeof CONTROL_ID_SCHEMAS,
|
||||
payload: unknown,
|
||||
): void {
|
||||
const parsed = CONTROL_ID_SCHEMAS[method].safeParse(payload)
|
||||
if (!parsed.success) {
|
||||
return rejectControl('bad-request', `invalid payload for ${method}`, {
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one durable listing onto the catalog view, replacing each row's
|
||||
* store-derived activity with the live Agent driver's status and reporting
|
||||
* whether the exact parent Agent is live. Without an Agent registry no driver
|
||||
* runs at all, so every row is inactive and the parent is unavailable.
|
||||
* @param ctx - Host context that may carry the Agent registry.
|
||||
* @param parentSessionId - the listed parent.
|
||||
* @param entries - the durable direct-child listing.
|
||||
* @returns the catalog view answered to one browser.
|
||||
*/
|
||||
export function catalogView(
|
||||
ctx: Context,
|
||||
parentSessionId: SessionId,
|
||||
entries: readonly SubagentListEntry[],
|
||||
): SubagentCatalog {
|
||||
const agents = ctx.get('agents')
|
||||
return {
|
||||
entries: entries.map((entry): SubagentListEntry => entry.kind === 'child'
|
||||
? { ...entry, activity: agents?.get(entry.id)?.status === 'running' ? 'running' : 'inactive' }
|
||||
: entry),
|
||||
parentAvailable: agents?.get(parentSessionId) !== undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse one catalog read while preserving cancellation and a missing
|
||||
* projections registry as distinct failures.
|
||||
* @param error - the thrown value.
|
||||
* @param signal - the caller's cancellation.
|
||||
* @returns Never — the refusal is thrown.
|
||||
* @throws {TypertRemoteFailure} always.
|
||||
*/
|
||||
export function rejectCatalogRead(error: unknown, signal: AbortSignal): never {
|
||||
if (isCancellation(error, signal)) {
|
||||
return rejectControl('cancelled', 'subagent catalog read was cancelled', {})
|
||||
}
|
||||
if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
|
||||
return rejectControl(
|
||||
'subagent-projections-unavailable',
|
||||
'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
{},
|
||||
)
|
||||
}
|
||||
return rejectControl('internal', 'subagent catalog read failed', {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse one continuation prompt without exposing provider detail: admission
|
||||
* failures the caller can act on keep their own code, everything else is
|
||||
* internal.
|
||||
* @param error - the thrown value.
|
||||
* @param childSessionId - the addressed child.
|
||||
* @param signal - the caller's cancellation.
|
||||
* @returns Never — the refusal is thrown.
|
||||
* @throws {TypertRemoteFailure} always.
|
||||
*/
|
||||
export function rejectPrompt(error: unknown, childSessionId: SessionId, signal: AbortSignal): never {
|
||||
if (isCancellation(error, signal)) {
|
||||
return rejectControl('cancelled', 'subagent prompt was cancelled', {})
|
||||
}
|
||||
if (error instanceof SubagentError) {
|
||||
switch (error.code) {
|
||||
case 'NOT_RESUMABLE':
|
||||
return rejectControl('subagent-not-resumable', 'subagent cannot be resumed', { childSessionId })
|
||||
case 'UNAUTHORIZED':
|
||||
return rejectControl(
|
||||
'subagent-unauthorized',
|
||||
'subagent does not belong to this parent',
|
||||
{ childSessionId },
|
||||
)
|
||||
case 'DRAINING':
|
||||
case 'ACTIVATION_CLOSING':
|
||||
case 'CONTINUATION_UNAVAILABLE':
|
||||
case 'PERSISTENCE_UNAVAILABLE':
|
||||
return rejectControl(
|
||||
'subagent-delivery-unavailable',
|
||||
'subagent follow-up is temporarily unavailable',
|
||||
{ childSessionId },
|
||||
)
|
||||
// A code outside the admission vocabulary is not the caller's move to make.
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return rejectControl('internal', 'subagent prompt failed', {})
|
||||
}
|
||||
|
||||
function isCancellation(error: unknown, signal: AbortSignal): boolean {
|
||||
return signal.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')
|
||||
}
|
||||
@@ -29,13 +29,25 @@
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
canonicalClientTimeZone, catalogView, rejectCatalogRead, rejectControl, rejectPrompt,
|
||||
validateControlRequest,
|
||||
} from './control.ts'
|
||||
import type {
|
||||
SubagentCatalog,
|
||||
SubagentInterruptReceipt,
|
||||
SubagentPromptReceipt,
|
||||
SubagentPromptRequest,
|
||||
SubagentPromptRequestId,
|
||||
} from './control-types.ts'
|
||||
import type {
|
||||
ContinuableCreateRequest,
|
||||
ContinuableCreateSpec,
|
||||
@@ -121,7 +133,8 @@ export type {
|
||||
SubagentSettledMessageSource,
|
||||
} from './continuation.ts'
|
||||
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
export type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts'
|
||||
export type * from './control-types.ts'
|
||||
export type { SubagentDescendantListEntry } from './list-children.ts'
|
||||
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
@@ -166,8 +179,21 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable attribution of one browser-authored follow-up. The Session
|
||||
* Controller declares this `user-rpc` message source and depends on this
|
||||
* package, so the fields are spelled here: `MessageSource`'s `user` member
|
||||
* accepts the record and the correlation id rides the durable message the
|
||||
* Client reconciles its optimistic prompt against.
|
||||
*/
|
||||
interface BrowserPromptSource {
|
||||
readonly kind: 'user'
|
||||
readonly rpcId: SubagentPromptRequestId
|
||||
readonly clientTimeZone?: string
|
||||
}
|
||||
|
||||
/** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */
|
||||
export class SubagentRuntime extends Service {
|
||||
export class SubagentRuntime extends TypertRemoteService {
|
||||
private providers = new Map<string, SubagentProvider>()
|
||||
private continuations: SubagentContinuationManager | undefined
|
||||
/** Deployment contributions composed into unpublished continuable children. */
|
||||
@@ -363,6 +389,114 @@ export class SubagentRuntime extends Service {
|
||||
return listSubagentDescendants(this.ctx, rootSessionId, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote face of {@link listChildren} for one browser: the durable listing
|
||||
* plus live Agent activity and the delivery-time parent availability hint.
|
||||
* Parent availability is a hint; {@link prompt} performs the authoritative
|
||||
* check. Named apart from the provider-name {@link list}, which owns the
|
||||
* member.
|
||||
* @param parentSessionId - parent session whose direct children are listed.
|
||||
* @param signal - carrier cancellation forwarded to Session queries.
|
||||
* @returns the catalog view for that parent.
|
||||
* @throws {TypertRemoteFailure} `bad-request` for an empty parent id,
|
||||
* `cancelled` for an aborted read, `subagent-projections-unavailable` when
|
||||
* the deployment has no projection registry, otherwise `internal`.
|
||||
*/
|
||||
@Remote('list')
|
||||
async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise<SubagentCatalog> {
|
||||
validateControlRequest('subagent.list', { parentSessionId })
|
||||
try {
|
||||
return catalogView(this.ctx, parentSessionId, await this.listChildren(parentSessionId, signal))
|
||||
} catch (error: unknown) {
|
||||
return rejectCatalogRead(error, signal)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one browser-authored message to a continuable child through the
|
||||
* exact live direct parent, retaining the caller-minted request identity and
|
||||
* validated browser zone on the accepted message. Success identifies the
|
||||
* message the child's FIFO inbox accepted; later execution is independent of
|
||||
* this call.
|
||||
* @param request - durable address, minted identity, content, and optional browser zone.
|
||||
* @param signal - carrier cancellation, owning the call until inbox acceptance.
|
||||
* @returns the accepted message's inbox identity.
|
||||
* @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`,
|
||||
* `subagent-parent-unavailable`, `subagent-not-resumable`,
|
||||
* `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or
|
||||
* `internal`.
|
||||
*/
|
||||
@Remote('prompt')
|
||||
async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt> {
|
||||
const { parentSessionId, childSessionId, clientTimeZone } = request
|
||||
validateControlRequest('subagent.prompt', request)
|
||||
const canonicalTimeZone = clientTimeZone === undefined
|
||||
? undefined
|
||||
: canonicalClientTimeZone(clientTimeZone)
|
||||
if (clientTimeZone !== undefined && canonicalTimeZone === undefined) {
|
||||
return rejectControl(
|
||||
'invalid-time-zone',
|
||||
'clientTimeZone must be UTC or a valid IANA Area/Location name',
|
||||
{ value: clientTimeZone },
|
||||
)
|
||||
}
|
||||
const parent = this.ctx.get('agents')?.get(parentSessionId)
|
||||
if (parent === undefined) {
|
||||
return rejectControl(
|
||||
'subagent-parent-unavailable',
|
||||
`parent session "${parentSessionId}" is not live`,
|
||||
{ parentSessionId },
|
||||
)
|
||||
}
|
||||
const source: BrowserPromptSource = {
|
||||
kind: 'user',
|
||||
rpcId: request.requestId,
|
||||
...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }),
|
||||
}
|
||||
const content: ContentBlock[] = [...request.content]
|
||||
try {
|
||||
return { messageId: await this.followup(parent, childSessionId, content, { source, signal }) }
|
||||
} catch (error: unknown) {
|
||||
return rejectPrompt(error, childSessionId, signal)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote face of {@link interrupt} under one durable parent address. No
|
||||
* catalog, history, persistence, or parent Agent lookup runs: the core
|
||||
* primitive alone authorizes the address against the live Activation, which
|
||||
* is what keeps a live child interruptible while its parent Agent is offline.
|
||||
* Absent, idle, and already-completed targets are accepted no-ops there.
|
||||
* @param childSessionId - durable child session id to interrupt.
|
||||
* @param parentSessionId - durable direct parent whose authority is claimed.
|
||||
* @param mode - required continuable-address discriminator.
|
||||
* @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent.
|
||||
* @throws {TypertRemoteFailure} `bad-request` for an empty id,
|
||||
* `subagent-unauthorized` when the address does not own the live target,
|
||||
* otherwise `internal`.
|
||||
*/
|
||||
@Remote('interruptByParent')
|
||||
interruptByParent(
|
||||
childSessionId: SessionId,
|
||||
parentSessionId: SessionId,
|
||||
mode: 'continuable',
|
||||
): SubagentInterruptReceipt {
|
||||
validateControlRequest('subagent.interrupt', { childSessionId, parentSessionId, mode })
|
||||
try {
|
||||
this.interrupt(childSessionId, { kind: 'user', parentSessionId })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') {
|
||||
return rejectControl(
|
||||
'subagent-unauthorized',
|
||||
'subagent does not belong to this parent',
|
||||
{ childSessionId },
|
||||
)
|
||||
}
|
||||
return rejectControl('internal', 'subagent interrupt failed', {})
|
||||
}
|
||||
return { accepted: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a provider under its name. Registration is effect-scoped and HMR
|
||||
* safe; removing a provider blocks new starts but does not revoke runs that
|
||||
|
||||
@@ -21,9 +21,12 @@ import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session
|
||||
import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SubagentListEntry } from './control-types.ts'
|
||||
import { SubagentError } from './error.ts'
|
||||
import type { SubagentIdentityProjection } from './projection-types.ts'
|
||||
|
||||
export type { SubagentListEntry } from './control-types.ts'
|
||||
|
||||
/**
|
||||
* Concurrent cold observations per explicit catalog listing. Current Session
|
||||
* persistence providers are local; a networked provider must promote this to
|
||||
@@ -31,61 +34,6 @@ import type { SubagentIdentityProjection } from './projection-types.ts'
|
||||
*/
|
||||
const COLD_READ_CONCURRENCY = 4
|
||||
|
||||
/**
|
||||
* One entry of a {@link listChildren} result, ordered by header `createdAt`
|
||||
* with ties broken on id. Only a candidate whose durable header has
|
||||
* `origin: 'subagent'` is interpreted. A served `subagent` projection value
|
||||
* produces a `child`; a settled candidate whose fold served no identity
|
||||
* produces a `diagnostic`; a running candidate without one is omitted — its
|
||||
* descriptor may not be appended yet (the creation window). Diagnostics
|
||||
* relay the projection fold's outcome or a failed read, never a per-child
|
||||
* event scan, and never expose model-hidden descriptor content.
|
||||
*/
|
||||
export type SubagentListEntry =
|
||||
| {
|
||||
readonly kind: 'child'
|
||||
/** The durable child session id, stable across Activations. */
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* Store snapshot activity: `running` means the logical record is live in
|
||||
* `ctx.sessions`; `inactive` means it exists only in persistence. Neither
|
||||
* encodes a durable outcome, and a continuable child may still reject
|
||||
* delivery as an ownership conflict.
|
||||
*/
|
||||
readonly activity: 'running' | 'inactive'
|
||||
/** Whether a direct descendant has durable `origin: 'subagent'`. */
|
||||
readonly hasChildren: boolean
|
||||
} & (
|
||||
| {
|
||||
/** A terminal one-shot child. */
|
||||
readonly mode: 'one-shot'
|
||||
/** Optional durable creation label from the child's descriptor. */
|
||||
readonly label?: string
|
||||
}
|
||||
| {
|
||||
/** A resumable conversation. */
|
||||
readonly mode: 'continuable'
|
||||
/** Durable creation label from the child's descriptor. */
|
||||
readonly label: string
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly kind: 'diagnostic'
|
||||
/** The candidate's session id. */
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* Why the candidate has no `child` row: `corrupt` for a settled candidate
|
||||
* whose projection fold served no identity (a missing, malformed, or
|
||||
* unrecognized-version descriptor — deliberately undistinguished), and
|
||||
* for any candidate whose log makes a registered unit's fold or schema
|
||||
* throw (deterministic data damage, contained per child); `unavailable`
|
||||
* when the candidate's Session observation was absent or transiently
|
||||
* unreadable (retried on the next listing). `unsupported` is never produced; it remains in the
|
||||
* union for consumers that route on it.
|
||||
*/
|
||||
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of a descendant listing: the interpreted subagent facts plus its
|
||||
* position in the complete session tree. `parentId` is the durable direct
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
// The browser-facing control surface: catalog assembly against the live Agent
|
||||
// registry, prompt admission, and the stable failure codes each answers with.
|
||||
// The durable listing, continuation, and interrupt primitives they wrap have
|
||||
// their own specs, so each case scripts them.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SubagentRuntime, {
|
||||
SubagentError,
|
||||
type SubagentListEntry,
|
||||
type SubagentPromptRequestId,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
const PARENT = SessionId('parent')
|
||||
const CHILD = SessionId('child')
|
||||
const OTHER = SessionId('other')
|
||||
const BROKEN = SessionId('broken')
|
||||
const REQUEST_ID = 'req-1' as SubagentPromptRequestId
|
||||
const signal = new AbortController().signal
|
||||
|
||||
/** The runtime plus a programmable live-Agent registry, omitted to compose none. */
|
||||
async function bench(live?: Record<string, { status: 'running' | 'idle' }>) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
if (live !== undefined) {
|
||||
ctx.provide('agents', { get: (id: SessionId) => live[id] } as never)
|
||||
}
|
||||
return { ctx, subagents: ctx.subagents }
|
||||
}
|
||||
|
||||
function childRow(id: SessionId, activity: 'running' | 'inactive'): SubagentListEntry {
|
||||
return { kind: 'child', id, mode: 'continuable', label: 'worker', activity, hasChildren: false }
|
||||
}
|
||||
|
||||
function promptRequest(clientTimeZone?: string) {
|
||||
return {
|
||||
requestId: REQUEST_ID,
|
||||
parentSessionId: PARENT,
|
||||
childSessionId: CHILD,
|
||||
mode: 'continuable' as const,
|
||||
content: [{ type: 'text' as const, text: 'continue' }],
|
||||
...clientTimeZone === undefined ? {} : { clientTimeZone },
|
||||
}
|
||||
}
|
||||
|
||||
function emptyIdFailure(method: string, field: string) {
|
||||
return {
|
||||
code: 'bad-request',
|
||||
message: `invalid payload for ${method}`,
|
||||
details: {
|
||||
issues: [{
|
||||
origin: 'string',
|
||||
code: 'too_small',
|
||||
minimum: 1,
|
||||
inclusive: true,
|
||||
path: [field],
|
||||
message: 'Too small: expected string to have >=1 characters',
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('subagent catalog Remote', () => {
|
||||
it('rejects an empty parent id before listing', async () => {
|
||||
const { subagents } = await bench()
|
||||
const listChildren = vi.spyOn(subagents, 'listChildren')
|
||||
|
||||
await expect(subagents.remoteExportList(SessionId(''), signal))
|
||||
.rejects.toMatchObject({ failure: emptyIdFailure('subagent.list', 'parentSessionId') })
|
||||
expect(listChildren).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('samples row activity from the live Agent driver and reports parent availability', async () => {
|
||||
const { subagents } = await bench({ [PARENT]: { status: 'idle' }, [CHILD]: { status: 'running' } })
|
||||
vi.spyOn(subagents, 'listChildren').mockResolvedValue([
|
||||
// The durable listing reports store presence; the browser row reports the driver.
|
||||
childRow(CHILD, 'inactive'),
|
||||
childRow(OTHER, 'running'),
|
||||
{ kind: 'diagnostic', id: BROKEN, reason: 'corrupt' },
|
||||
])
|
||||
|
||||
await expect(subagents.remoteExportList(PARENT, signal)).resolves.toEqual({
|
||||
entries: [
|
||||
childRow(CHILD, 'running'),
|
||||
childRow(OTHER, 'inactive'),
|
||||
{ kind: 'diagnostic', id: BROKEN, reason: 'corrupt' },
|
||||
],
|
||||
parentAvailable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports every row inactive and the parent unavailable without an Agent registry', async () => {
|
||||
const { subagents } = await bench()
|
||||
vi.spyOn(subagents, 'listChildren').mockResolvedValue([childRow(CHILD, 'running')])
|
||||
|
||||
await expect(subagents.remoteExportList(PARENT, signal)).resolves.toEqual({
|
||||
entries: [childRow(CHILD, 'inactive')],
|
||||
parentAvailable: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an unknown parent as unavailable while the registry serves other sessions', async () => {
|
||||
const { subagents } = await bench({ [CHILD]: { status: 'running' } })
|
||||
vi.spyOn(subagents, 'listChildren').mockResolvedValue([])
|
||||
|
||||
await expect(subagents.remoteExportList(PARENT, signal))
|
||||
.resolves.toEqual({ entries: [], parentAvailable: false })
|
||||
})
|
||||
|
||||
it('separates cancellation, the missing projections capability, and an unexplained read failure', async () => {
|
||||
const { subagents } = await bench()
|
||||
const listChildren = vi.spyOn(subagents, 'listChildren')
|
||||
|
||||
const aborted = new AbortController()
|
||||
aborted.abort()
|
||||
listChildren.mockRejectedValue(new Error('read stopped'))
|
||||
await expect(subagents.remoteExportList(PARENT, aborted.signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'cancelled' } })
|
||||
|
||||
listChildren.mockRejectedValue(new SubagentError('cancelled', 'CANCELLED'))
|
||||
await expect(subagents.remoteExportList(PARENT, signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'cancelled' } })
|
||||
|
||||
listChildren.mockRejectedValue(
|
||||
new SubagentError('no registry', 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE'),
|
||||
)
|
||||
await expect(subagents.remoteExportList(PARENT, signal)).rejects.toMatchObject({
|
||||
failure: {
|
||||
code: 'subagent-projections-unavailable',
|
||||
message: expect.stringContaining('sessionProjections') as unknown as string,
|
||||
},
|
||||
})
|
||||
|
||||
listChildren.mockRejectedValue(new Error('disk gone'))
|
||||
await expect(subagents.remoteExportList(PARENT, signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'internal', message: 'subagent catalog read failed' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent prompt Remote', () => {
|
||||
it('rejects empty parent and child ids before delivery', async () => {
|
||||
const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
|
||||
const followup = vi.spyOn(subagents, 'followup')
|
||||
|
||||
const cases: readonly {
|
||||
readonly field: 'parentSessionId' | 'childSessionId'
|
||||
readonly request: ReturnType<typeof promptRequest>
|
||||
}[] = [
|
||||
{ field: 'parentSessionId', request: { ...promptRequest(), parentSessionId: SessionId('') } },
|
||||
{ field: 'childSessionId', request: { ...promptRequest(), childSessionId: SessionId('') } },
|
||||
]
|
||||
for (const { field, request } of cases) {
|
||||
await expect(subagents.prompt(request, signal))
|
||||
.rejects.toMatchObject({ failure: emptyIdFailure('subagent.prompt', field) })
|
||||
}
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards non-text content blocks without narrowing them', async () => {
|
||||
const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
|
||||
const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-content' as MessageId)
|
||||
const content = [{ type: 'reasoning' as const, text: 'retain this block' }]
|
||||
|
||||
await expect(subagents.prompt({ ...promptRequest(), content }, signal))
|
||||
.resolves.toEqual({ messageId: 'm-content' })
|
||||
expect(followup.mock.calls[0]?.[2]).toEqual(content)
|
||||
})
|
||||
|
||||
it('delivers the content under the caller-minted identity and canonical browser zone', async () => {
|
||||
const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
|
||||
const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-1' as MessageId)
|
||||
|
||||
await expect(subagents.prompt(promptRequest('Asia/Shanghai'), signal))
|
||||
.resolves.toEqual({ messageId: 'm-1' })
|
||||
expect(followup).toHaveBeenCalledWith(
|
||||
{ status: 'idle' },
|
||||
CHILD,
|
||||
[{ type: 'text', text: 'continue' }],
|
||||
{
|
||||
source: { kind: 'user', rpcId: REQUEST_ID, clientTimeZone: 'Asia/Shanghai' },
|
||||
signal,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the zone from the durable source when the browser reported none', async () => {
|
||||
const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
|
||||
const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-2' as MessageId)
|
||||
|
||||
await expect(subagents.prompt(promptRequest(), signal)).resolves.toEqual({ messageId: 'm-2' })
|
||||
expect(followup.mock.calls[0]?.[3].source).toEqual({ kind: 'user', rpcId: REQUEST_ID })
|
||||
})
|
||||
|
||||
it('accepts UTC and rejects an empty, untrimmed, malformed, or unknown zone', async () => {
|
||||
const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
|
||||
vi.spyOn(subagents, 'followup').mockResolvedValue('m-3' as MessageId)
|
||||
|
||||
await expect(subagents.prompt(promptRequest('UTC'), signal)).resolves.toEqual({ messageId: 'm-3' })
|
||||
for (const zone of ['', ' UTC', 'Shanghai', 'Nowhere/Nowhere']) {
|
||||
await expect(subagents.prompt(promptRequest(zone), signal)).rejects.toMatchObject({
|
||||
failure: { code: 'invalid-time-zone', details: { value: zone } },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses delivery when the exact parent Agent is not live', async () => {
|
||||
const { subagents } = await bench()
|
||||
const followup = vi.spyOn(subagents, 'followup')
|
||||
|
||||
await expect(subagents.prompt(promptRequest(), signal)).rejects.toMatchObject({
|
||||
failure: { code: 'subagent-parent-unavailable', details: { parentSessionId: PARENT } },
|
||||
})
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps each admission failure onto its stable code and hides the rest', async () => {
|
||||
const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
|
||||
const followup = vi.spyOn(subagents, 'followup')
|
||||
const cases: readonly [string, string][] = [
|
||||
['NOT_RESUMABLE', 'subagent-not-resumable'],
|
||||
['UNAUTHORIZED', 'subagent-unauthorized'],
|
||||
['DRAINING', 'subagent-delivery-unavailable'],
|
||||
['ACTIVATION_CLOSING', 'subagent-delivery-unavailable'],
|
||||
['NO_PROVIDER', 'internal'],
|
||||
]
|
||||
for (const [thrown, code] of cases) {
|
||||
followup.mockRejectedValue(new SubagentError('refused', thrown))
|
||||
await expect(subagents.prompt(promptRequest(), signal))
|
||||
.rejects.toMatchObject({ failure: { code } })
|
||||
}
|
||||
|
||||
followup.mockRejectedValue(new Error('inbox exploded'))
|
||||
await expect(subagents.prompt(promptRequest(), signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'internal', message: 'subagent prompt failed' } })
|
||||
})
|
||||
|
||||
it('answers a caller-cancelled delivery as cancelled rather than a failure', async () => {
|
||||
const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
|
||||
const aborted = new AbortController()
|
||||
vi.spyOn(subagents, 'followup').mockImplementation(() => {
|
||||
aborted.abort()
|
||||
return Promise.reject(new SubagentError('gone', 'NOT_RESUMABLE'))
|
||||
})
|
||||
|
||||
await expect(subagents.prompt(promptRequest(), aborted.signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'cancelled' } })
|
||||
})
|
||||
|
||||
it('preserves a cancellation reported by the continuation operation', async () => {
|
||||
const { subagents } = await bench({ [PARENT]: { status: 'idle' } })
|
||||
vi.spyOn(subagents, 'followup')
|
||||
.mockRejectedValue(new SubagentError('stopped', 'CANCELLED'))
|
||||
|
||||
await expect(subagents.prompt(promptRequest(), signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'cancelled' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent interrupt Remote', () => {
|
||||
it('rejects empty child and parent ids before interrupting', async () => {
|
||||
const { subagents } = await bench()
|
||||
const interrupt = vi.spyOn(subagents, 'interrupt')
|
||||
|
||||
for (const [childSessionId, parentSessionId] of [
|
||||
[SessionId(''), PARENT],
|
||||
[CHILD, SessionId('')],
|
||||
] as const) {
|
||||
const field = childSessionId.length === 0 ? 'childSessionId' : 'parentSessionId'
|
||||
expect(() => subagents.interruptByParent(childSessionId, parentSessionId, 'continuable'))
|
||||
.toThrow(expect.objectContaining({
|
||||
failure: emptyIdFailure('subagent.interrupt', field),
|
||||
}))
|
||||
}
|
||||
expect(interrupt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('admits the parent-addressed interrupt and acknowledges it', async () => {
|
||||
const { subagents } = await bench()
|
||||
const interrupt = vi.spyOn(subagents, 'interrupt').mockReturnValue()
|
||||
|
||||
expect(subagents.interruptByParent(CHILD, PARENT, 'continuable')).toEqual({ accepted: true })
|
||||
expect(interrupt).toHaveBeenCalledWith(CHILD, { kind: 'user', parentSessionId: PARENT })
|
||||
})
|
||||
|
||||
it('answers a foreign address as unauthorized and everything else as internal', async () => {
|
||||
const { subagents } = await bench()
|
||||
const interrupt = vi.spyOn(subagents, 'interrupt')
|
||||
|
||||
interrupt.mockImplementation(() => { throw new SubagentError('not yours', 'UNAUTHORIZED') })
|
||||
expect(() => subagents.interruptByParent(CHILD, PARENT, 'continuable')).toThrow(
|
||||
expect.objectContaining({ failure: { code: 'subagent-unauthorized', message: expect.any(String) as unknown as string, details: { childSessionId: CHILD } } }),
|
||||
)
|
||||
|
||||
interrupt.mockImplementation(() => { throw new Error('boom') })
|
||||
expect(() => subagents.interruptByParent(CHILD, PARENT, 'continuable')).toThrow(
|
||||
expect.objectContaining({ failure: { code: 'internal', message: 'subagent interrupt failed', details: {} } }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,9 @@
|
||||
{
|
||||
"path": "../../jobs/jobs"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
@@ -63,6 +64,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
SessionEventLikeEntry, SessionLiveEventEntry, SessionSearchResultItem,
|
||||
SessionSnapshot, SessionSummary,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
{
|
||||
"path": "../../client/ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
|
||||
Generated
+9
-3
@@ -722,6 +722,9 @@ importers:
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings
|
||||
'@deepseek-ai/dsh-subagent':
|
||||
specifier: workspace:^
|
||||
version: link:../../subagent/subagent
|
||||
'@deepseek-ai/dsh-user-approval':
|
||||
specifier: workspace:^
|
||||
version: link:../../interaction/user-approval
|
||||
@@ -5715,9 +5718,6 @@ importers:
|
||||
'@deepseek-ai/dsh-skill':
|
||||
specifier: workspace:^
|
||||
version: link:../../skill/skill
|
||||
'@deepseek-ai/dsh-subagent':
|
||||
specifier: workspace:^
|
||||
version: link:../../subagent/subagent
|
||||
'@deepseek-ai/dsh-util-crypto':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/crypto
|
||||
@@ -8244,6 +8244,9 @@ importers:
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/protocol
|
||||
'@deepseek-ai/dsh-user-approval':
|
||||
specifier: workspace:^
|
||||
version: link:../../interaction/user-approval
|
||||
@@ -9052,6 +9055,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-subagent':
|
||||
specifier: workspace:^
|
||||
version: link:../../subagent/subagent
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
|
||||
@@ -465,10 +465,14 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ContinuableStart: 'subagent.md',
|
||||
ContinuableStartSpec: 'subagent.md',
|
||||
CoordinatorMessageSource: 'subagent.md',
|
||||
SubagentCatalog: 'subagent.md',
|
||||
SubagentDescendantListEntry: 'subagent.md',
|
||||
SubagentFollowupOptions: 'subagent.md',
|
||||
SubagentInterruptAuthority: 'subagent.md',
|
||||
SubagentInterruptReceipt: 'subagent.md',
|
||||
SubagentListEntry: 'subagent.md',
|
||||
SubagentPromptReceipt: 'subagent.md',
|
||||
SubagentPromptRequest: 'subagent.md',
|
||||
SubagentProvider: 'subagent.md',
|
||||
SubagentReportDelivery: 'subagent.md',
|
||||
SubagentReportMessageSource: 'subagent.md',
|
||||
|
||||
Reference in New Issue
Block a user