feat(gui): add ask-user question composer
This commit is contained in:
@@ -14,12 +14,16 @@ Introduce `dsh-user-interaction` as the provider-neutral interface package for `
|
|||||||
|
|
||||||
The model-facing request vocabulary is deliberately aligned with the product-research schema: `ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`. `id` is supplied per question and echoed in the result so a batch can be routed without relying on question text. `label` is both user-facing display text and the selected value returned to the model; there is no separate `value`, no `recommended`, no `allow_custom`, and no `desc` alias.
|
The model-facing request vocabulary is deliberately aligned with the product-research schema: `ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`. `id` is supplied per question and echoed in the result so a batch can be routed without relying on question text. `label` is both user-facing display text and the selected value returned to the model; there is no separate `value`, no `recommended`, no `allow_custom`, and no `desc` alias.
|
||||||
|
|
||||||
Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty.
|
Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty. A provider that supports partial completion represents a deliberately skipped item with the existing `{ id, selected: [] }` shape, preserving the other answers without extending the tool result vocabulary.
|
||||||
|
|
||||||
`UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, ACP cancellation, or missing session routing survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception.
|
`UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, ACP cancellation, or missing session routing survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception.
|
||||||
|
|
||||||
## UI mappings
|
## UI mappings
|
||||||
|
|
||||||
|
`dsh web` mounts `dsh-client-ui-question`, whose host half opts the Web product into the model-facing tool and whose browser half registers a `question` entry in the conversation-owned keyed composer slot. `createApiProxy` implements the Web provider with a process-memory pending table keyed by a host-minted rpcId. It registers the wait before broadcasting `question/requested`, replays the same id on every mux reopen, validates the session and complete answer batch before claiming it, and broadcasts `question/resolved` after answer, cancellation, abort, or disposal. Claiming deletes the entry synchronously, so the first valid response wins and duplicate or late responses return `not-pending`.
|
||||||
|
|
||||||
|
The Web composer shows one question at a time while retaining every request in the session object layer. It supports single-select, multi-select, optionless or explicit custom answers, description text, and a visual recommendation badge without selecting the recommendation automatically. Single-select choices advance to the next item immediately, and Enter submits when every item is answered or explicitly skipped; Enter during IME composition only confirms the input candidate. The footer skips only the current item and preserves earlier drafts; the close control rejects the whole tool call with `ASK_CANCELLED`. The normal composer returns only after the host's resolved frame removes the pending item.
|
||||||
|
|
||||||
`dsh-tui` renders each question as a keyboard overlay, shows option descriptions, supports single- and multi-select choices plus free-form custom answers, and rejects pending questions on abort, provider disposal, or terminal shutdown. Batched and simultaneous requests are queued so one overlay owns keyboard focus at a time.
|
`dsh-tui` renders each question as a keyboard overlay, shows option descriptions, supports single- and multi-select choices plus free-form custom answers, and rejects pending questions on abort, provider disposal, or terminal shutdown. Batched and simultaneous requests are queued so one overlay owns keyboard focus at a time.
|
||||||
|
|
||||||
`dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s.
|
`dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s.
|
||||||
@@ -42,8 +46,8 @@ ACP elicitation is currently marked unstable in the SDK. The fallback is still s
|
|||||||
|
|
||||||
The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it.
|
The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it.
|
||||||
|
|
||||||
`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests.
|
`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `dsh web` boots the seam/provider in the host runtime and exposes the tool through the selected Web question plugin. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop.
|
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, explicit per-item skips, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. Web tests pin stable-id replay, response validation, first-wins settlement, duplicate and late responses, whole-request cancellation versus owner abort, single-select advance, IME-safe Enter submission, per-item skip preservation, composer takeover, structured batch submission, and restoration of the normal composer.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
|
// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
|
||||||
// registry surface + __DSH_BOOT__ injection + built shell dist in a real
|
// registry surface + __DSH_BOOT__ injection + built shell dist in a real
|
||||||
// chromium. First describe: manifest injection + fail-loud half. Second
|
// chromium. First describe: manifest injection + fail-loud half. Second
|
||||||
// describe: the settled success pass — five REAL tsdown bundles (the
|
// describe: the settled success pass — all nine REAL tsdown bundles load
|
||||||
// infrastructure four + layout) load through the DI chain in ?fixture mode
|
// through the DI chain in ?fixture mode, the three-column frame appears in
|
||||||
// and the three-column frame appears in one flip. The full conversation
|
// one flip, and the resident question completes through the real UI stack.
|
||||||
// round lands in smoke-real under the W5 real-host standard.
|
// The full model round lands in smoke-real under the W5 real-host standard.
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { Browser, Page } from 'playwright'
|
import type { Browser, Page } from 'playwright'
|
||||||
@@ -17,13 +17,17 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo
|
|||||||
const bundlePath = (dir: string): string =>
|
const bundlePath = (dir: string): string =>
|
||||||
fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
|
fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
|
||||||
|
|
||||||
/** id ↔ bundle table for the success pass (immediately four + layout). */
|
/** id ↔ bundle table for the success pass (the complete Web UI assembly). */
|
||||||
const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [
|
const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [
|
||||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true },
|
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true },
|
||||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true },
|
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true },
|
||||||
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true },
|
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true },
|
||||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Manifest served by the fake registry: one live bundle row, one missing row. */
|
/** Manifest served by the fake registry: one live bundle row, one missing row. */
|
||||||
@@ -90,7 +94,7 @@ describe('web boot chain (keyless, real carrier)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => {
|
describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
|
||||||
const missing = REAL_PLUGINS.filter((p) => !existsSync(bundlePath(p.dir)))
|
const missing = REAL_PLUGINS.filter((p) => !existsSync(bundlePath(p.dir)))
|
||||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||||
let browser: Browser
|
let browser: Browser
|
||||||
@@ -141,6 +145,43 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', (
|
|||||||
expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout')
|
expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders and completes the resident question through the composer slot', async () => {
|
||||||
|
onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
|
||||||
|
await page.getByText('fixture', { exact: true }).click()
|
||||||
|
await page.locator('[role="treeitem"]').nth(1).click()
|
||||||
|
const composer = page.locator('[data-question-rpc-id]')
|
||||||
|
await composer.waitFor({ timeout: 15_000 })
|
||||||
|
expect({
|
||||||
|
question: await composer.getByRole('heading').innerText(),
|
||||||
|
progress: await composer.getByText('1 / 3', { exact: true }).innerText(),
|
||||||
|
options: await composer.getByRole('radio').allTextContents(),
|
||||||
|
custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(),
|
||||||
|
}).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"custom": "其他,请填写自定义答案",
|
||||||
|
"options": [
|
||||||
|
"1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。",
|
||||||
|
"2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。",
|
||||||
|
"3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。",
|
||||||
|
],
|
||||||
|
"progress": "1 / 3",
|
||||||
|
"question": "你现在更想招哪类 Agent/Harness 候选人?",
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
await composer.getByRole('radio', { name: '工程落地型' }).click()
|
||||||
|
await composer.getByText('2 / 3', { exact: true }).waitFor()
|
||||||
|
await composer.getByRole('button', { name: '跳过本题', exact: true }).click()
|
||||||
|
await composer.getByRole('checkbox', { name: '系统设计' }).click()
|
||||||
|
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click()
|
||||||
|
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter')
|
||||||
|
|
||||||
|
await composer.waitFor({ state: 'detached' })
|
||||||
|
const restoredInput = page.locator('textarea[placeholder]')
|
||||||
|
await restoredInput.waitFor()
|
||||||
|
expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入')
|
||||||
|
})
|
||||||
|
|
||||||
it('stayed clean: no page errors across the whole load chain', () => {
|
it('stayed clean: no page errors across the whole load chain', () => {
|
||||||
expect(pageErrors).toEqual([])
|
expect(pageErrors).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,10 +75,10 @@ async function detailsTrack(page: Page): Promise<number> {
|
|||||||
return Number(cols.split(' ').pop()!.replace('px', ''))
|
return Number(cols.split(' ').pop()!.replace('px', ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Readiness gate: `dsh web` serves ALL eight manifest plugins; until every UI
|
// Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI
|
||||||
// plugin's client bundle exists and exports apply, the loader fail-louds and
|
// plugin's client bundle exists and exports apply, the loader fail-louds and
|
||||||
// the frame never appears.
|
// the frame never appears.
|
||||||
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-trajectory']
|
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
|
||||||
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
|
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
|
||||||
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
|
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
|
||||||
return !existsSync(bundle) || !readFileSync(bundle, 'utf8').includes('exports.apply')
|
return !existsSync(bundle) || !readFileSync(bundle, 'utf8').includes('exports.apply')
|
||||||
|
|||||||
@@ -1743,6 +1743,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
|||||||
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
|
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
|
||||||
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
|
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
|
||||||
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
|
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
|
||||||
|
- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
|
||||||
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
|
- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
|
||||||
- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
|
- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
|
||||||
- `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
|
- `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
|
||||||
|
|||||||
@@ -56,14 +56,14 @@ interface AskUserQuestionRequest {
|
|||||||
|
|
||||||
## Answer
|
## Answer
|
||||||
|
|
||||||
Providers return one answer per answered question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices.
|
Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch.
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/** Answer to one question. */
|
/** Answer to one question. */
|
||||||
interface AskUserQuestionAnswerItem {
|
interface AskUserQuestionAnswerItem {
|
||||||
/** The answered question id. */
|
/** The answered question id. */
|
||||||
id: string
|
id: string
|
||||||
/** Selected option labels. Empty when the answer is purely custom text. */
|
/** Selected option labels. Empty for custom or unanswered choices. */
|
||||||
selected: string[]
|
selected: string[]
|
||||||
/** Optional free-text "Other" answer. */
|
/** Optional free-text "Other" answer. */
|
||||||
custom?: string
|
custom?: string
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ flowchart TD
|
|||||||
pkg_client_ui_conversation["client-ui-conversation"]
|
pkg_client_ui_conversation["client-ui-conversation"]
|
||||||
pkg_client_ui_layout["client-ui-layout"]
|
pkg_client_ui_layout["client-ui-layout"]
|
||||||
pkg_client_ui_primitives["client-ui-primitives"]
|
pkg_client_ui_primitives["client-ui-primitives"]
|
||||||
|
pkg_client_ui_question["client-ui-question"]
|
||||||
pkg_client_ui_sidebar["client-ui-sidebar"]
|
pkg_client_ui_sidebar["client-ui-sidebar"]
|
||||||
pkg_client_ui_slots["client-ui-slots"]
|
pkg_client_ui_slots["client-ui-slots"]
|
||||||
pkg_client_ui_theme["client-ui-theme"]
|
pkg_client_ui_theme["client-ui-theme"]
|
||||||
@@ -207,6 +208,7 @@ flowchart TD
|
|||||||
pkg_client_ui_conversation --> pkg_invariants
|
pkg_client_ui_conversation --> pkg_invariants
|
||||||
pkg_client_ui_layout --> pkg_invariants
|
pkg_client_ui_layout --> pkg_invariants
|
||||||
pkg_client_ui_primitives --> pkg_invariants
|
pkg_client_ui_primitives --> pkg_invariants
|
||||||
|
pkg_client_ui_question --> pkg_invariants
|
||||||
pkg_client_ui_sidebar --> pkg_invariants
|
pkg_client_ui_sidebar --> pkg_invariants
|
||||||
pkg_client_ui_slots --> pkg_invariants
|
pkg_client_ui_slots --> pkg_invariants
|
||||||
pkg_client_ui_theme --> pkg_invariants
|
pkg_client_ui_theme --> pkg_invariants
|
||||||
@@ -706,6 +708,7 @@ flowchart TD
|
|||||||
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
|
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
|
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
|
||||||
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
|
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
|
||||||
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
|
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
|
||||||
// prompt triggers a chunked streaming replay; cancel stops the replay; one resident pending
|
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
|
||||||
// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse).
|
// approval/question requests exercise replay and composer takeover with stable rpcIds.
|
||||||
|
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||||
@@ -249,6 +249,40 @@ export function createFixtureApi(): ApiProxy {
|
|||||||
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
|
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
|
||||||
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
|
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
|
||||||
const pendingApprovalRpcId = mint()
|
const pendingApprovalRpcId = mint()
|
||||||
|
const pendingQuestionRpcId = mint()
|
||||||
|
let questionPending = true
|
||||||
|
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
|
||||||
|
{
|
||||||
|
id: 'harness-profile',
|
||||||
|
header: '偏好',
|
||||||
|
question: '你现在更想招哪类 Agent/Harness 候选人?',
|
||||||
|
options: [
|
||||||
|
{ label: '工程落地型 (Recommended)', description: '更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。' },
|
||||||
|
{ label: '研究潜力型', description: '更看重 Agent 理解、训练评测思路和长期成长空间。' },
|
||||||
|
{ label: '均衡型', description: '同时要求工程能力和 Agent 认知,但可能筛选门槛更高。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'work-mode',
|
||||||
|
header: '方式',
|
||||||
|
question: '你希望候选人优先展示哪种工作方式?',
|
||||||
|
options: [
|
||||||
|
{ label: '先做小型原型 (Recommended)', description: '用可运行结果尽快验证关键假设。' },
|
||||||
|
{ label: '先写完整设计', description: '先收敛边界、协议和风险,再开始实现。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'signals',
|
||||||
|
header: '信号',
|
||||||
|
question: '哪些面试信号最重要?',
|
||||||
|
multiSelect: true,
|
||||||
|
options: [
|
||||||
|
{ label: '系统设计' },
|
||||||
|
{ label: '代码质量' },
|
||||||
|
{ label: 'Agent 产品判断' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const muxConns = new Set<StreamConn<MuxFrame>>()
|
const muxConns = new Set<StreamConn<MuxFrame>>()
|
||||||
const hostConns = new Set<StreamConn<HostFrame>>()
|
const hostConns = new Set<StreamConn<HostFrame>>()
|
||||||
@@ -429,7 +463,7 @@ export function createFixtureApi(): ApiProxy {
|
|||||||
muxConns.add(conn)
|
muxConns.add(conn)
|
||||||
const breakNow = (): void => { conn.breakNow() }
|
const breakNow = (): void => { conn.breakNow() }
|
||||||
streamBreakers.add(breakNow)
|
streamBreakers.add(breakNow)
|
||||||
// Open baseline: subscribed for attached (running) sessions + pending approval replay (stable rpcId).
|
// Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds.
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
if (!s.running) continue
|
if (!s.running) continue
|
||||||
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
|
conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
|
||||||
@@ -442,6 +476,14 @@ export function createFixtureApi(): ApiProxy {
|
|||||||
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
|
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if (questionPending) {
|
||||||
|
conn.push({
|
||||||
|
rpcId: pendingQuestionRpcId,
|
||||||
|
payload: {
|
||||||
|
type: 'question/requested', sessionId: sid('fx-alpha'), questions: fixtureQuestions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
yield* conn.drain(signal)
|
yield* conn.drain(signal)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -471,9 +513,16 @@ export function createFixtureApi(): ApiProxy {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||||
// The v1 UI never answers (PendingCard is visible but not answerable); implemented for type completeness, always not-pending.
|
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
|
||||||
void message
|
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
}
|
||||||
|
questionPending = false
|
||||||
|
emitMux({
|
||||||
|
type: 'question/resolved', sessionId: sid('fx-alpha'),
|
||||||
|
questionRpcId: pendingQuestionRpcId,
|
||||||
|
outcome: message.result.ok ? 'answered' : 'cancelled',
|
||||||
|
})
|
||||||
|
return Promise.resolve({ accepted: true })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,14 +148,14 @@ describe('createFixtureApi', () => {
|
|||||||
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
|
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
|
||||||
})
|
})
|
||||||
|
|
||||||
it('mux open replays the baseline: subscribed for running sessions + the resident approval with a stable rpcId', async () => {
|
it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => {
|
||||||
const api = createFixtureApi()
|
const api = createFixtureApi()
|
||||||
const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
|
const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||||
envelopes.push(envelope)
|
envelopes.push(envelope)
|
||||||
if (envelopes.length >= 2) abort.abort()
|
if (envelopes.length >= 3) abort.abort()
|
||||||
}
|
}
|
||||||
return envelopes
|
return envelopes
|
||||||
}
|
}
|
||||||
@@ -165,6 +165,8 @@ describe('createFixtureApi', () => {
|
|||||||
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
||||||
expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||||
expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
|
expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||||
|
expect(first[2]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||||
|
expect(second[2]?.rpcId).toBe(first[2]?.rpcId)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||||
@@ -217,9 +219,38 @@ describe('createFixtureApi', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('respond is a typed stub: always not-pending', async () => {
|
it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => {
|
||||||
const api = createFixtureApi()
|
const api = createFixtureApi()
|
||||||
expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
|
expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
|
||||||
|
const abort = new AbortController()
|
||||||
|
let question: RpcRequest<MuxFrame> | undefined
|
||||||
|
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||||
|
if (envelope.payload.type !== 'question/requested') continue
|
||||||
|
question = envelope
|
||||||
|
abort.abort()
|
||||||
|
}
|
||||||
|
if (question === undefined) throw new Error('fixture question missing')
|
||||||
|
const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } }
|
||||||
|
expect(await api.respond(response)).toEqual({ accepted: true })
|
||||||
|
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
|
||||||
|
|
||||||
|
const replayAbort = new AbortController()
|
||||||
|
const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2)
|
||||||
|
expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true)
|
||||||
|
|
||||||
|
const cancelledApi = createFixtureApi()
|
||||||
|
const cancelAbort = new AbortController()
|
||||||
|
let cancelQuestion: RpcRequest<MuxFrame> | undefined
|
||||||
|
for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) {
|
||||||
|
if (envelope.payload.type !== 'question/requested') continue
|
||||||
|
cancelQuestion = envelope
|
||||||
|
cancelAbort.abort()
|
||||||
|
}
|
||||||
|
if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing')
|
||||||
|
expect(await cancelledApi.respond({
|
||||||
|
type: 'client-response', rpcId: cancelQuestion.rpcId,
|
||||||
|
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
|
||||||
|
})).toEqual({ accepted: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('describe answers the fixture identity', async () => {
|
it('describe answers the fixture identity', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// string here (narrow to real brands when convenient).
|
// string here (narrow to real brands when convenient).
|
||||||
|
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||||
import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
import type { MuxFrame, RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
|
|
||||||
/** Assistant content blocks sorted by what the UI cares about
|
/** Assistant content blocks sorted by what the UI cares about
|
||||||
* (text body / collapsible reasoning / tool-call card head / other fallback). */
|
* (text body / collapsible reasoning / tool-call card head / other fallback). */
|
||||||
@@ -121,11 +121,14 @@ export interface RunningToolCall {
|
|||||||
callView: ToolCallView | null
|
callView: ToolCallView | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Approval/question placeholder cards (visible, not answerable;
|
/** Approval/question pending state; rpcId is the requested frame's response-backfill key. */
|
||||||
* rpcId = the requested frame's envelope id, the future respond backfill key). */
|
|
||||||
export type PendingInteraction =
|
export type PendingInteraction =
|
||||||
| { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string }
|
| { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string }
|
||||||
| { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] }
|
| {
|
||||||
|
kind: 'question'
|
||||||
|
rpcId: RpcId
|
||||||
|
questions: readonly Extract<MuxFrame, { type: 'question/requested' }>['questions'][number][]
|
||||||
|
}
|
||||||
|
|
||||||
/** In-progress assistant output (chunk accumulator product). */
|
/** In-progress assistant output (chunk accumulator product). */
|
||||||
export interface PartialAssistant {
|
export interface PartialAssistant {
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
|
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||||
import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
|
import type {
|
||||||
|
HistoryEntry, IApiClient, MuxFrame, QuestionResponsePayload, RpcError, RpcId, RpcReceipt, RpcResult,
|
||||||
|
SessionId, ToolEventView,
|
||||||
|
} from '@deepseek-ai/dsh-client-connection/client'
|
||||||
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
|
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||||
@@ -120,6 +123,34 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer one host-owned question wait; pending clears only on the authoritative resolved frame.
|
||||||
|
* @param rpcId - Stable id from the requested frame.
|
||||||
|
* @param answer - Complete structured answer batch.
|
||||||
|
* @returns Carrier receipt; rejection leaves pending state unchanged.
|
||||||
|
*/
|
||||||
|
answerQuestion(rpcId: RpcId, answer: QuestionResponsePayload['answer']): Promise<RpcReceipt> {
|
||||||
|
return this.api.respond({
|
||||||
|
type: 'client-response', rpcId,
|
||||||
|
result: { ok: true, value: { sessionId: this.sessionId, answer } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel one host-owned question wait without encoding closure as skipped answers.
|
||||||
|
* @param rpcId - Stable id from the requested frame.
|
||||||
|
* @returns Carrier receipt; rejection leaves pending state unchanged.
|
||||||
|
*/
|
||||||
|
cancelQuestion(rpcId: RpcId): Promise<RpcReceipt> {
|
||||||
|
return this.api.respond({
|
||||||
|
type: 'client-response', rpcId,
|
||||||
|
result: {
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
|
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
|
||||||
open(): Promise<void> {
|
open(): Promise<void> {
|
||||||
if (this.openState === 'open') return Promise.resolve()
|
if (this.openState === 'open') return Promise.resolve()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// data source on a real clock; behavior tests need per-case responses and
|
// data source on a real clock; behavior tests need per-case responses and
|
||||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||||
import type {
|
import type {
|
||||||
HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
|
ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
|
||||||
} from '@deepseek-ai/dsh-client-connection/client'
|
} from '@deepseek-ai/dsh-client-connection/client'
|
||||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ export class FakeApiClient implements IApiClient {
|
|||||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||||
|
onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
|
||||||
|
|
||||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||||
@@ -93,8 +94,8 @@ export class FakeApiClient implements IApiClient {
|
|||||||
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
|
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
|
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
return this.record('respond', message, this.onRespond(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */
|
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */
|
||||||
|
|||||||
@@ -251,6 +251,30 @@ describe('pending interactions', () => {
|
|||||||
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
|
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
|
||||||
expect(session.getSnapshot().pending).toEqual([])
|
expect(session.getSnapshot().pending).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('backfills the requested rpcId for structured answers and explicit cancellation', async () => {
|
||||||
|
const { api, session } = makeSession()
|
||||||
|
await session.answerQuestion('rq-answer' as never, {
|
||||||
|
answers: [{ id: 'mode', selected: ['Fast'] }],
|
||||||
|
})
|
||||||
|
await session.cancelQuestion('rq-cancel' as never)
|
||||||
|
expect(api.callsOf('respond')).toEqual([
|
||||||
|
{
|
||||||
|
type: 'client-response', rpcId: 'rq-answer',
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'client-response', rpcId: 'rq-cancel',
|
||||||
|
result: {
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('remaining branches', () => {
|
describe('remaining branches', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# @deepseek-ai/dsh-client-ui-conversation
|
# @deepseek-ai/dsh-client-ui-conversation
|
||||||
|
|
||||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7.
|
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. The keyed `conversation.composer` slot lets pending interaction features replace InputBar without moving interaction state into the skeleton. Contract: api-contracts v3 §7.
|
||||||
|
|
||||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains.
|
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains.
|
||||||
|
|
||||||
@@ -18,5 +18,5 @@ None; this package neither assembles nor sends a provider request.
|
|||||||
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
|
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
|
||||||
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
|
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
|
||||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||||
- **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project.
|
- **Approval cards are display-only placeholders** — question requests use the composer slot, while Web approval answering remains deferred.
|
||||||
- **Module-level toolview caches are single-bundle state** — the inject cache and registry maps must reach cross-bundle consumers through the package export surface and loader module table, never by a second bundle copy.
|
- **Module-level toolview caches are single-bundle state** — the inject cache and registry maps must reach cross-bundle consumers through the package export surface and loader module table, never by a second bundle copy.
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export function apply(ctx: Context): void {
|
|||||||
const layout = need<LayoutService>(ctx, 'layout')
|
const layout = need<LayoutService>(ctx, 'layout')
|
||||||
const i18n = need<I18nService>(ctx, 'i18n')
|
const i18n = need<I18nService>(ctx, 'i18n')
|
||||||
const slots = need<SlotsService>(ctx, 'slots')
|
const slots = need<SlotsService>(ctx, 'slots')
|
||||||
|
slots.define('conversation.composer', { kind: 'keyed', scope: 'session' })
|
||||||
|
const composerSlots = scopedSlots(slots.core, 'conversation.composer')
|
||||||
|
|
||||||
const conversation = new ConversationService(ctx)
|
const conversation = new ConversationService(ctx)
|
||||||
const toolviews = new ToolViewRegistry()
|
const toolviews = new ToolViewRegistry()
|
||||||
@@ -153,6 +155,7 @@ export function apply(ctx: Context): void {
|
|||||||
}
|
}
|
||||||
return createElement(Fragment, null, ...children)
|
return createElement(Fragment, null, ...children)
|
||||||
},
|
},
|
||||||
|
slots: composerSlots,
|
||||||
}
|
}
|
||||||
return injected
|
return injected
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,7 +270,9 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
|
{pending.map((item) => item.kind === 'approval'
|
||||||
|
? <PendingCard key={item.rpcId} item={item} />
|
||||||
|
: null)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!atBottom && (
|
{!atBottom && (
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
// PendingCard: approval/question placeholder card (visible, not answerable —
|
// PendingCard: approval placeholder card. Questions take over the composer.
|
||||||
// the composer-takeover approval panel is a P-II item; wire pending semantics
|
|
||||||
// already exist so the flow must show them).
|
|
||||||
|
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
||||||
import css from './PendingCard.module.css'
|
import css from './PendingCard.module.css'
|
||||||
|
|
||||||
export interface PendingCardProps {
|
export interface PendingCardProps {
|
||||||
item: PendingInteraction
|
item: Extract<PendingInteraction, { kind: 'approval' }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
|
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className={css.card}>
|
<div className={css.card}>
|
||||||
{item.kind === 'approval' ? (
|
<div className={css.title}>等待审批:<span className={css.mono}>{item.toolName}</span></div>
|
||||||
<>
|
{item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
|
||||||
<div className={css.title}>等待审批:<span className={css.mono}>{item.toolName}</span></div>
|
|
||||||
{item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={css.title}>等待回答({item.questions.length} 题)</div>
|
|
||||||
<JsonBlock label="问题内容" payload={item.questions} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>
|
<div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
* standard share & own injected share.
|
* standard share & own injected share.
|
||||||
*/
|
*/
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { PendingInteraction, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
|
import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||||
import type { SelectionTarget, ViewEntry, ViewId } from './views.ts'
|
import type { SelectionTarget, ViewEntry, ViewId } from './views.ts'
|
||||||
@@ -38,6 +39,13 @@ export interface ConversationInjected {
|
|||||||
}
|
}
|
||||||
/** Renders the active view's body (the owner closes over ConvViewProps assembly). */
|
/** Renders the active view's body (the owner closes over ConvViewProps assembly). */
|
||||||
renderView: (entry: ViewEntry) => ReactNode
|
renderView: (entry: ViewEntry) => ReactNode
|
||||||
|
/** Feature-owned composer replacements, dispatched by pending interaction kind. */
|
||||||
|
slots: ScopedSlots<'conversation.composer'>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Question-composer owner share supplied by ConversationRoot. */
|
||||||
|
export interface QuestionComposerOwnerProps {
|
||||||
|
interaction: Extract<PendingInteraction, { kind: 'question' }>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full conversation-slot component props: owner share & standard share & injected share. */
|
/** Full conversation-slot component props: owner share & standard share & injected share. */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { ConversationService } from './service.ts'
|
import type { ConversationService } from './service.ts'
|
||||||
import type { ToolViewRegistry } from './toolviews/registry.ts'
|
import type { ToolViewRegistry } from './toolviews/registry.ts'
|
||||||
|
import type { QuestionComposerOwnerProps } from './contract/slots.ts'
|
||||||
|
|
||||||
export { apply, inject } from './apply.ts'
|
export { apply, inject } from './apply.ts'
|
||||||
export { ConversationService } from './service.ts'
|
export { ConversationService } from './service.ts'
|
||||||
@@ -22,7 +23,7 @@ export type {
|
|||||||
} from './contract/toolview.ts'
|
} from './contract/toolview.ts'
|
||||||
export type {
|
export type {
|
||||||
ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
|
ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
|
||||||
EmptyStateInjected, EmptyStateSlotProps,
|
EmptyStateInjected, EmptyStateSlotProps, QuestionComposerOwnerProps,
|
||||||
} from './contract/slots.ts'
|
} from './contract/slots.ts'
|
||||||
|
|
||||||
export { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
export { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||||
@@ -40,3 +41,13 @@ declare module 'cordis' {
|
|||||||
toolviews: ToolViewRegistry
|
toolviews: ToolViewRegistry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||||
|
interface SlotMap {
|
||||||
|
'conversation.composer': {
|
||||||
|
kind: 'keyed'
|
||||||
|
scope: 'session'
|
||||||
|
owner: QuestionComposerOwnerProps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { useSyncExternalStore } from 'react'
|
import { useSyncExternalStore } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { ConversationSlotProps } from '../contract/slots.ts'
|
import type { ConversationSlotProps } from '../contract/slots.ts'
|
||||||
import { InputBar } from './InputBar.tsx'
|
import { InputBar } from './InputBar.tsx'
|
||||||
import type { InputBarError } from './InputBar.tsx'
|
import type { InputBarError } from './InputBar.tsx'
|
||||||
@@ -20,7 +21,7 @@ import css from './ConversationRoot.module.css'
|
|||||||
export type ConversationRootProps = ConversationSlotProps
|
export type ConversationRootProps = ConversationSlotProps
|
||||||
|
|
||||||
export function ConversationRoot({
|
export function ConversationRoot({
|
||||||
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView,
|
sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView, slots,
|
||||||
}: ConversationRootProps) {
|
}: ConversationRootProps) {
|
||||||
useSyncExternalStore(views.subscribe, views.version)
|
useSyncExternalStore(views.subscribe, views.version)
|
||||||
const list = views.list()
|
const list = views.list()
|
||||||
@@ -33,6 +34,9 @@ export function ConversationRoot({
|
|||||||
const removed = useSession(s => (s as { removed: boolean }).removed)
|
const removed = useSession(s => (s as { removed: boolean }).removed)
|
||||||
const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
|
const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
|
||||||
const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
|
const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
|
||||||
|
const question = useSession(s => (
|
||||||
|
s as { pending: readonly PendingInteraction[] }
|
||||||
|
).pending.find(item => item.kind === 'question'))
|
||||||
|
|
||||||
const error: InputBarError | null = promptError === null
|
const error: InputBarError | null = promptError === null
|
||||||
? null
|
? null
|
||||||
@@ -87,20 +91,37 @@ export function ConversationRoot({
|
|||||||
{active !== undefined && renderView(active)}
|
{active !== undefined && renderView(active)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InputBar
|
{question?.kind === 'question'
|
||||||
draft={draft}
|
? slots.renderSlot('conversation.composer', { interaction: question }, {
|
||||||
running={running}
|
entryKey: 'question',
|
||||||
disabled={removed}
|
fallback: <ComposerInput {...{ draft, running, removed, error, composer }} />,
|
||||||
error={error}
|
})
|
||||||
variant="composer"
|
: <ComposerInput {...{ draft, running, removed, error, composer }} />}
|
||||||
onDraftChange={composer.setDraft}
|
|
||||||
onSend={composer.send}
|
|
||||||
onStop={composer.stop}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ComposerInput({ draft, running, removed, error, composer }: {
|
||||||
|
draft: string
|
||||||
|
running: boolean
|
||||||
|
removed: boolean
|
||||||
|
error: InputBarError | null
|
||||||
|
composer: ConversationSlotProps['composer']
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InputBar
|
||||||
|
draft={draft}
|
||||||
|
running={running}
|
||||||
|
disabled={removed}
|
||||||
|
error={error}
|
||||||
|
variant="composer"
|
||||||
|
onDraftChange={composer.setDraft}
|
||||||
|
onSend={composer.send}
|
||||||
|
onStop={composer.stop}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Turn count = user message nodes in the window (display meta; exact host count deferred). */
|
/** Turn count = user message nodes in the window (display meta; exact host count deferred). */
|
||||||
function countTurns(s: { nodes: readonly { kind: string }[] }): number {
|
function countTurns(s: { nodes: readonly { kind: string }[] }): number {
|
||||||
let n = 0
|
let n = 0
|
||||||
|
|||||||
@@ -295,11 +295,18 @@ describe('ChatView', () => {
|
|||||||
expect(lv.getByText('载入历史…')).toBeTruthy()
|
expect(lv.getByText('载入历史…')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pending interactions render placeholder cards', () => {
|
it('renders approval cards while questions stay in the composer', () => {
|
||||||
const h = makeHarness({
|
const h = makeHarness({
|
||||||
pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }],
|
pending: [
|
||||||
|
{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' },
|
||||||
|
{
|
||||||
|
kind: 'question', rpcId: 'r2' as never,
|
||||||
|
questions: [{ id: 'mode', question: 'Composer only?', options: [{ label: 'Yes' }] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
const view = render(<h.ChatView {...h.props} />)
|
const view = render(<h.ChatView {...h.props} />)
|
||||||
expect(view.getByText(/等待审批/)).toBeTruthy()
|
expect(view.getByText(/等待审批/)).toBeTruthy()
|
||||||
|
expect(view.queryByText('Composer only?')).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
|
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
|
||||||
// PendingCard question arm, bash sample error pill, registry disposer
|
// Bash sample error pill and registry disposer
|
||||||
// idempotence re-entry, register.ts explicit bashSampleScope override, the
|
// idempotence re-entry, register.ts explicit bashSampleScope override, the
|
||||||
// node-half empty apply, and AssistantMarkdown reasoning/unknown block arms.
|
// node-half empty apply, and AssistantMarkdown reasoning/unknown block arms.
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { cleanup, render } from '@testing-library/react'
|
import { cleanup, render } from '@testing-library/react'
|
||||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
|
||||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import { apply as nodeApply } from '../src/index.ts'
|
import { apply as nodeApply } from '../src/index.ts'
|
||||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||||
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
|
|
||||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||||
import { registerChat } from '../src/client/chat/register.ts'
|
import { registerChat } from '../src/client/chat/register.ts'
|
||||||
@@ -34,13 +32,6 @@ describe('tails', () => {
|
|||||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('PendingCard renders the question arm with its count', () => {
|
|
||||||
const view = render(
|
|
||||||
<PendingCard item={{ kind: 'question', rpcId: 'r1' as RpcId, questions: [{}, {}] }} />,
|
|
||||||
)
|
|
||||||
expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
|
||||||
const view = render(
|
const view = render(
|
||||||
<AssistantMarkdown
|
<AssistantMarkdown
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
|||||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { ConversationRoot, DetailsPanel, EmptyState } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import { ConversationRoot, DetailsPanel, EmptyState } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type { ConversationInjected, SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
const SID = 's1' as SessionId
|
const SID = 's1' as SessionId
|
||||||
|
const fallbackSlots: ConversationInjected['slots'] = {
|
||||||
|
renderSlot: (_key, _props, opts) => opts?.fallback ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
function snapshotBase(): ConversationSnapshot {
|
function snapshotBase(): ConversationSnapshot {
|
||||||
return {
|
return {
|
||||||
@@ -55,6 +58,7 @@ describe('ConversationRoot branches', () => {
|
|||||||
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
|
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
|
||||||
actions={{ openView: vi.fn(), open }}
|
actions={{ openView: vi.fn(), open }}
|
||||||
renderView={() => <div data-testid="view-body" />}
|
renderView={() => <div data-testid="view-body" />}
|
||||||
|
slots={fallbackSlots}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
return { view, open }
|
return { view, open }
|
||||||
@@ -99,6 +103,7 @@ describe('ConversationRoot branches', () => {
|
|||||||
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
|
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
|
||||||
actions={{ openView: vi.fn(), open: vi.fn() }}
|
actions={{ openView: vi.fn(), open: vi.fn() }}
|
||||||
renderView={(entry) => <div data-testid={`body-${entry.id}`} />}
|
renderView={(entry) => <div data-testid={`body-${entry.id}`} />}
|
||||||
|
slots={fallbackSlots}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
expect(view.getByTestId('body-chat')).toBeTruthy()
|
expect(view.getByTestId('body-chat')).toBeTruthy()
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { PendingInteraction, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import {
|
import {
|
||||||
ConversationRoot, DetailsPanel, EmptyState,
|
ConversationRoot, DetailsPanel, EmptyState,
|
||||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import type { SelectionTarget, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type { ConversationInjected, SelectionTarget, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
|
|
||||||
const sid = (s: string): SessionId => s as SessionId
|
const sid = (s: string): SessionId => s as SessionId
|
||||||
|
|
||||||
@@ -28,11 +28,12 @@ interface FakeSnapshot {
|
|||||||
running: boolean
|
running: boolean
|
||||||
removed: boolean
|
removed: boolean
|
||||||
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
|
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
|
||||||
|
pending: readonly PendingInteraction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeSession(init: Partial<FakeSnapshot> = {}) {
|
function fakeSession(init: Partial<FakeSnapshot> = {}) {
|
||||||
const store = createSnapshotStore<FakeSnapshot>({
|
const store = createSnapshotStore<FakeSnapshot>({
|
||||||
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
|
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
|
||||||
})
|
})
|
||||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
|
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
|
||||||
}
|
}
|
||||||
@@ -67,8 +68,11 @@ describe('EmptyState', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('ConversationRoot', () => {
|
describe('ConversationRoot', () => {
|
||||||
function bench(views: ViewEntry[], active?: string) {
|
function bench(
|
||||||
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
|
views: ViewEntry[], active?: string, init: Partial<FakeSnapshot> = {},
|
||||||
|
renderSlot?: ConversationInjected['slots']['renderSlot'],
|
||||||
|
) {
|
||||||
|
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
|
||||||
const activeStore = createSnapshotStore<string | undefined>(active)
|
const activeStore = createSnapshotStore<string | undefined>(active)
|
||||||
const openView = vi.fn((v: string) => { activeStore.set(v) })
|
const openView = vi.fn((v: string) => { activeStore.set(v) })
|
||||||
const open = vi.fn()
|
const open = vi.fn()
|
||||||
@@ -98,6 +102,7 @@ describe('ConversationRoot', () => {
|
|||||||
}}
|
}}
|
||||||
actions={{ openView: openView as (v: never) => void, open }}
|
actions={{ openView: openView as (v: never) => void, open }}
|
||||||
renderView={(entry) => { rendered.push(entry.id); return <div data-testid={`view-${entry.id}`} /> }}
|
renderView={(entry) => { rendered.push(entry.id); return <div data-testid={`view-${entry.id}`} /> }}
|
||||||
|
slots={{ renderSlot: renderSlot ?? ((_key, _props, opts) => opts?.fallback ?? null) } as ConversationInjected['slots']}
|
||||||
/>)
|
/>)
|
||||||
return { ui, openView, open, rendered, send, drafts }
|
return { ui, openView, open, rendered, send, drafts }
|
||||||
}
|
}
|
||||||
@@ -133,6 +138,23 @@ describe('ConversationRoot', () => {
|
|||||||
fireEvent.keyDown(box, { key: 'Enter' })
|
fireEvent.keyDown(box, { key: 'Enter' })
|
||||||
expect(send).toHaveBeenCalledWith('queue')
|
expect(send).toHaveBeenCalledWith('queue')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('dispatches a pending question to the composer slot instead of rendering InputBar', () => {
|
||||||
|
const renderSlot = vi.fn(() => <div>question takeover</div>) as unknown as ConversationInjected['slots']['renderSlot']
|
||||||
|
bench([view('chat', 'Chat')], undefined, {
|
||||||
|
pending: [{
|
||||||
|
kind: 'question', rpcId: 'rq' as never,
|
||||||
|
questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }],
|
||||||
|
}],
|
||||||
|
}, renderSlot)
|
||||||
|
expect(screen.getByText('question takeover')).toBeTruthy()
|
||||||
|
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
|
||||||
|
expect(renderSlot).toHaveBeenCalledWith(
|
||||||
|
'conversation.composer',
|
||||||
|
expect.objectContaining({ interaction: expect.objectContaining({ rpcId: 'rq' }) }),
|
||||||
|
expect.objectContaining({ entryKey: 'question' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('DetailsPanel', () => {
|
describe('DetailsPanel', () => {
|
||||||
|
|||||||
20
packages/client/ui-question/README.md
Normal file
20
packages/client/ui-question/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# @deepseek-ai/dsh-client-ui-question
|
||||||
|
|
||||||
|
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
|
||||||
|
|
||||||
|
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||||
|
|
||||||
|
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
|
||||||
|
|
||||||
|
## Model Experience
|
||||||
|
|
||||||
|
Indirectly, through `dsh-tool-ask-user`; that package owns the model-visible tool schema and structured result.
|
||||||
|
|
||||||
|
#### KV Cache effect
|
||||||
|
|
||||||
|
No direct invalidation; `dsh-tool-ask-user` owns the model-visible tool call and result.
|
||||||
|
|
||||||
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
|
- **Unsubmitted drafts are not durable** — reconnect resync or a full page reload restores the host-owned pending request with the same rpcId, but a composer unmount resets local option and custom-text drafts.
|
||||||
|
- **One request owns the composer at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves.
|
||||||
66
packages/client/ui-question/package.json
Normal file
66
packages/client/ui-question/package.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "@deepseek-ai/dsh-client-ui-question",
|
||||||
|
"description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"types": "lib/types/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./lib/types/index.d.ts",
|
||||||
|
"default": "./lib/index.js"
|
||||||
|
},
|
||||||
|
"./invariant": {
|
||||||
|
"types": "./lib/types/invariant.d.ts",
|
||||||
|
"default": "./lib/invariant.js"
|
||||||
|
},
|
||||||
|
"./client": {
|
||||||
|
"types": "./lib/types/client/index.d.ts",
|
||||||
|
"default": "./lib/client.js"
|
||||||
|
},
|
||||||
|
"./src/*": "./src/*",
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"dshClient": {
|
||||||
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-ui-conversation"
|
||||||
|
],
|
||||||
|
"platform": "web"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"bundle": "tsdown",
|
||||||
|
"watch": "tsdown --watch"
|
||||||
|
},
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"react": "^18.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||||
|
"cordis": "^4.0.0-rc.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||||
|
"@types/react": "~18.3.1",
|
||||||
|
"cordis": "^4.0.0-rc.7"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"lib/index.js",
|
||||||
|
"lib/invariant.js",
|
||||||
|
"lib/client.js",
|
||||||
|
"lib/types/**/*.d.ts",
|
||||||
|
"lib/types/**/*.d.ts.map",
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
.frame {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 24px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--dsw-specific-input-major);
|
||||||
|
box-shadow: var(--dsw-shadow-lv1-blur);
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.card * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headingBlock {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiSelectHint {
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerActions,
|
||||||
|
.footerActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
padding: 0 6px;
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 24px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton:hover:not(:disabled) {
|
||||||
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton:disabled {
|
||||||
|
color: var(--dsw-alias-label-dimmed);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option:hover:not(:disabled),
|
||||||
|
.optionSelected {
|
||||||
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionSelected {
|
||||||
|
border-color: var(--dsw-alias-border-l2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option:disabled,
|
||||||
|
.customTrigger:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--dsw-alias-bg-module-platform);
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionCopy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionLine {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--dsw-alias-bg-module-platform);
|
||||||
|
color: var(--dsw-alias-label-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceIcon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 20px;
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customOpen {
|
||||||
|
border-color: var(--dsw-alias-border-l2);
|
||||||
|
background: var(--dsw-alias-bg-module-platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customOptionless {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customTrigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customTrigger:hover:not(:disabled) {
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customInput {
|
||||||
|
display: block;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
min-height: 54px;
|
||||||
|
max-height: 140px;
|
||||||
|
margin: 0 10px 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2);
|
||||||
|
border-radius: 10px;
|
||||||
|
outline: none;
|
||||||
|
background: var(--dsw-specific-input-major);
|
||||||
|
color: var(--dsw-alias-label-primary);
|
||||||
|
caret-color: var(--dsw-alias-state-business-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customInput:focus {
|
||||||
|
border-color: var(--dsw-alias-state-business-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customInput::placeholder {
|
||||||
|
color: var(--dsw-alias-label-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customOptionless .customInput {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 58px;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--dsw-alias-bg-module-platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback {
|
||||||
|
min-height: 16px;
|
||||||
|
color: var(--dsw-alias-state-error-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.frame {
|
||||||
|
padding: 6px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 12px 10px 10px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headingBlock {
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option,
|
||||||
|
.customTrigger {
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceIcon {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerActions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.option {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
308
packages/client/ui-question/src/client/QuestionComposer.tsx
Normal file
308
packages/client/ui-question/src/client/QuestionComposer.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { useState, type KeyboardEvent } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
|
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import {
|
||||||
|
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
|
||||||
|
IconCloseOutline16, IconEditOutline16,
|
||||||
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
|
import type { QuestionComposerOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
|
import css from './QuestionComposer.module.css'
|
||||||
|
|
||||||
|
type QuestionInteraction = Extract<PendingInteraction, { kind: 'question' }>
|
||||||
|
type Answer = QuestionResponsePayload['answer']
|
||||||
|
|
||||||
|
interface DraftAnswer {
|
||||||
|
selected: string[]
|
||||||
|
custom: string
|
||||||
|
customOpen: boolean
|
||||||
|
skipped: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actions assembled from the session object layer. */
|
||||||
|
export interface QuestionComposerInjected {
|
||||||
|
actions: {
|
||||||
|
answer(interaction: QuestionInteraction, answer: Answer): Promise<void>
|
||||||
|
cancel(interaction: QuestionInteraction): Promise<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full question-composer props. */
|
||||||
|
export type QuestionComposerProps = QuestionComposerOwnerProps & QuestionComposerInjected
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split the conventional recommendation suffix without changing the answer value.
|
||||||
|
* @param label - Original option label returned if selected.
|
||||||
|
* @returns Display label plus recommendation state.
|
||||||
|
*/
|
||||||
|
export function parseRecommendedLabel(label: string): { label: string; recommended: boolean } {
|
||||||
|
const suffix = /\s*(?:\((?:recommended|推荐)\)|((?:recommended|推荐)))\s*$/i
|
||||||
|
return suffix.test(label)
|
||||||
|
? { label: label.replace(suffix, ''), recommended: true }
|
||||||
|
: { label, recommended: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a conventional multi-select suffix so the hint can be styled separately.
|
||||||
|
* @param title - Question title supplied by the interaction request.
|
||||||
|
* @returns Question title without a trailing multi-select marker.
|
||||||
|
*/
|
||||||
|
export function parseQuestionTitle(title: string): string {
|
||||||
|
return title.replace(/\s*[((]可多选[))]\s*$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return whether a textarea key event belongs to an active IME composition. */
|
||||||
|
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||||
|
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composer takeover boundary; rpcId keys local drafts while same-id replay preserves them.
|
||||||
|
* @param props - Pending interaction and scoped answer/cancel actions.
|
||||||
|
* @returns The question flow for this request.
|
||||||
|
*/
|
||||||
|
export function QuestionComposer(props: QuestionComposerProps) {
|
||||||
|
return <QuestionFlow key={props.interaction.rpcId} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionFlow({ interaction, actions }: QuestionComposerProps) {
|
||||||
|
const questions = interaction.questions
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
|
||||||
|
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
|
||||||
|
})))
|
||||||
|
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const question = questions[index]!
|
||||||
|
const draft = drafts[index]!
|
||||||
|
const hasOptions = (question.options?.length ?? 0) > 0
|
||||||
|
|
||||||
|
const cancelFlow = (): void => {
|
||||||
|
setBusy('cancel')
|
||||||
|
setError(null)
|
||||||
|
void actions.cancel(interaction).catch((cause: unknown) => {
|
||||||
|
setBusy(null)
|
||||||
|
setError(cause instanceof Error ? cause.message : String(cause))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => {
|
||||||
|
setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item))
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const choose = (label: string): void => {
|
||||||
|
updateDraft((current) => {
|
||||||
|
const selected = question.multiSelect === true
|
||||||
|
? current.selected.includes(label)
|
||||||
|
? current.selected.filter(item => item !== label)
|
||||||
|
: [...current.selected, label]
|
||||||
|
: [label]
|
||||||
|
return { selected, custom: '', customOpen: false, skipped: false }
|
||||||
|
})
|
||||||
|
if (question.multiSelect !== true && index < questions.length - 1) {
|
||||||
|
setIndex(current => current + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCustom = (): void => {
|
||||||
|
updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const answered = (item: DraftAnswer): boolean =>
|
||||||
|
item.selected.length > 0 || item.custom.trim() !== ''
|
||||||
|
|
||||||
|
const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped
|
||||||
|
|
||||||
|
const submitDrafts = (values: DraftAnswer[]): void => {
|
||||||
|
const missing = values.findIndex(item => !completed(item))
|
||||||
|
if (missing >= 0) {
|
||||||
|
setIndex(missing)
|
||||||
|
setError('请先完成这道问题。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const answer: Answer = {
|
||||||
|
answers: questions.map((item, itemIndex) => {
|
||||||
|
const value = values[itemIndex] as DraftAnswer
|
||||||
|
if (value.skipped) return { id: item.id, selected: [] }
|
||||||
|
const custom = value.custom.trim()
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
selected: custom === '' ? value.selected : [],
|
||||||
|
...(custom === '' ? {} : { custom }),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
setBusy('answer')
|
||||||
|
setError(null)
|
||||||
|
void actions.answer(interaction, answer).catch((cause: unknown) => {
|
||||||
|
setBusy(null)
|
||||||
|
setError(cause instanceof Error ? cause.message : String(cause))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const continueFlow = (): void => {
|
||||||
|
if (!answered(draft)) {
|
||||||
|
setError('请选择一个选项或填写自定义答案。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (index < questions.length - 1) {
|
||||||
|
setIndex(current => current + 1)
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitDrafts(drafts)
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipQuestion = (): void => {
|
||||||
|
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
|
||||||
|
? {
|
||||||
|
selected: [], custom: '',
|
||||||
|
customOpen: (question.options?.length ?? 0) === 0,
|
||||||
|
skipped: true,
|
||||||
|
}
|
||||||
|
: item)
|
||||||
|
setDrafts(nextDrafts)
|
||||||
|
setError(null)
|
||||||
|
if (index < questions.length - 1) {
|
||||||
|
setIndex(current => current + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitDrafts(nextDrafts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.frame} data-question-rpc-id={interaction.rpcId}>
|
||||||
|
<section className={css.card} aria-labelledby={`question-${interaction.rpcId}-${String(index)}`}>
|
||||||
|
<header className={css.header}>
|
||||||
|
<div className={css.headingBlock}>
|
||||||
|
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
|
||||||
|
<h2 className={css.title} id={`question-${interaction.rpcId}-${String(index)}`}>
|
||||||
|
<span>{question.multiSelect === true
|
||||||
|
? parseQuestionTitle(question.question)
|
||||||
|
: question.question}</span>
|
||||||
|
{question.multiSelect === true && <span className={css.multiSelectHint}>可多选</span>}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className={css.headerActions}>
|
||||||
|
<span className={css.progress}>{index + 1} / {questions.length}</span>
|
||||||
|
<button
|
||||||
|
type="button" className={css.iconButton} aria-label="上一题"
|
||||||
|
disabled={index === 0 || busy !== null}
|
||||||
|
onClick={() => { setIndex(index - 1); setError(null) }}
|
||||||
|
>
|
||||||
|
<IconChevronLeftOutline14 />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button" className={css.iconButton} aria-label="下一题"
|
||||||
|
disabled={index === questions.length - 1 || busy !== null}
|
||||||
|
onClick={() => { setIndex(index + 1); setError(null) }}
|
||||||
|
>
|
||||||
|
<IconChevronRightOutline14 />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button" className={css.iconButton} aria-label="放弃整组问题"
|
||||||
|
title="放弃整组问题"
|
||||||
|
disabled={busy !== null} onClick={cancelFlow}
|
||||||
|
>
|
||||||
|
<IconCloseOutline16 />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
|
||||||
|
{(question.options ?? []).map((option, optionIndex) => {
|
||||||
|
const selected = draft.selected.includes(option.label)
|
||||||
|
const display = parseRecommendedLabel(option.label)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button" key={`${option.label}-${String(optionIndex)}`}
|
||||||
|
className={clsx(css.option, selected && css.optionSelected)}
|
||||||
|
role={question.multiSelect === true ? 'checkbox' : 'radio'}
|
||||||
|
aria-checked={selected}
|
||||||
|
aria-label={display.label}
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => { choose(option.label) }}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter' || !drafts.every(completed)) return
|
||||||
|
event.preventDefault()
|
||||||
|
submitDrafts(drafts)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={css.number}>{optionIndex + 1}</span>
|
||||||
|
<span className={css.optionCopy}>
|
||||||
|
<span className={css.optionLine}>
|
||||||
|
<span className={css.optionLabel}>{display.label}</span>
|
||||||
|
{display.recommended && <span className={css.badge}>推荐</span>}
|
||||||
|
{option.description !== undefined && (
|
||||||
|
<span className={css.description}>{option.description}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={css.choiceIcon}>
|
||||||
|
{selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className={clsx(
|
||||||
|
css.custom,
|
||||||
|
draft.customOpen && css.customOpen,
|
||||||
|
!hasOptions && css.customOptionless,
|
||||||
|
)}>
|
||||||
|
{hasOptions && (
|
||||||
|
<button
|
||||||
|
type="button" className={css.customTrigger}
|
||||||
|
disabled={busy !== null} onClick={openCustom}
|
||||||
|
aria-expanded={draft.customOpen}
|
||||||
|
>
|
||||||
|
<span className={css.number}><IconEditOutline16 /></span>
|
||||||
|
<span>其他,请填写自定义答案</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{draft.customOpen && (
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
className={css.customInput}
|
||||||
|
value={draft.custom}
|
||||||
|
disabled={busy !== null}
|
||||||
|
rows={2}
|
||||||
|
placeholder="输入你的答案"
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value
|
||||||
|
updateDraft(current => ({
|
||||||
|
...current, selected: [], custom: value, customOpen: true, skipped: false,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
|
||||||
|
event.preventDefault()
|
||||||
|
continueFlow()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className={css.footer}>
|
||||||
|
<div className={css.feedback} role="status">{error}</div>
|
||||||
|
<div className={css.footerActions}>
|
||||||
|
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
|
||||||
|
跳过本题
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary" size="sm"
|
||||||
|
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
|
||||||
|
>
|
||||||
|
{busy === 'answer'
|
||||||
|
? '正在提交…'
|
||||||
|
: index === questions.length - 1 ? '提交' : '下一题'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
packages/client/ui-question/src/client/index.ts
Normal file
47
packages/client/ui-question/src/client/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Web question plugin, browser half: registers a composer replacement for
|
||||||
|
* pending ask_user_question requests.
|
||||||
|
*/
|
||||||
|
import type { Context } from 'cordis'
|
||||||
|
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import { QuestionComposer, type QuestionComposerInjected } from './QuestionComposer.tsx'
|
||||||
|
|
||||||
|
export { QuestionComposer, parseRecommendedLabel } from './QuestionComposer.tsx'
|
||||||
|
export type { QuestionComposerInjected, QuestionComposerProps } from './QuestionComposer.tsx'
|
||||||
|
|
||||||
|
/** Required browser services. */
|
||||||
|
export const inject = ['slots', 'sessions']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the question composer into the conversation-owned keyed slot.
|
||||||
|
* @param ctx - Browser plugin context carrying slots and sessions.
|
||||||
|
*/
|
||||||
|
export function apply(ctx: Context): void {
|
||||||
|
const slots = ctx.get('slots')
|
||||||
|
const sessions = ctx.get('sessions') as SessionsService | undefined
|
||||||
|
if (slots === undefined || sessions === undefined) {
|
||||||
|
throw new Error('ui-question: slots and sessions services are required')
|
||||||
|
}
|
||||||
|
slots.register<'conversation.composer', QuestionComposerInjected>('conversation.composer', QuestionComposer, {
|
||||||
|
key: 'question',
|
||||||
|
inject(binding): QuestionComposerInjected {
|
||||||
|
const session = sessions.manager.get(binding.sessionId as SessionId)
|
||||||
|
return {
|
||||||
|
actions: {
|
||||||
|
async answer(interaction, answer) {
|
||||||
|
const receipt = await session.answerQuestion(interaction.rpcId, answer)
|
||||||
|
if (!receipt.accepted) {
|
||||||
|
throw new Error(`question response rejected: ${receipt.reason}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async cancel(interaction) {
|
||||||
|
const receipt = await session.cancelQuestion(interaction.rpcId)
|
||||||
|
if (!receipt.accepted) {
|
||||||
|
throw new Error(`question cancellation rejected: ${receipt.reason}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
4
packages/client/ui-question/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-question/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.module.css' {
|
||||||
|
const classes: Readonly<Record<string, string>>
|
||||||
|
export default classes
|
||||||
|
}
|
||||||
17
packages/client/ui-question/src/index.ts
Normal file
17
packages/client/ui-question/src/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Web question plugin, node half: enabling this UI feature also exposes the
|
||||||
|
* model-facing ask_user_question tool on the host composition.
|
||||||
|
*/
|
||||||
|
import type { Context } from 'cordis'
|
||||||
|
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||||
|
|
||||||
|
/** Host services required by the model-facing tool. */
|
||||||
|
export const inject = ['tools', 'userInteraction']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount ask_user_question for hosts that selected the Web question plugin.
|
||||||
|
* @param ctx - Host plugin context carrying tools and userInteraction.
|
||||||
|
*/
|
||||||
|
export function apply(ctx: Context): void {
|
||||||
|
toolAskUser.apply(ctx)
|
||||||
|
}
|
||||||
31
packages/client/ui-question/src/invariant.ts
Normal file
31
packages/client/ui-question/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-question`.
|
||||||
|
* @module @deepseek-ai/dsh-client-ui-question/invariant
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* jscpd:ignore-start */
|
||||||
|
import type { Context } from 'cordis'
|
||||||
|
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||||
|
|
||||||
|
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-question'
|
||||||
|
|
||||||
|
/** Cordis companion plugin name. */
|
||||||
|
export const name = 'client-ui-question-invariant'
|
||||||
|
/** Service required before the companion can reserve package ownership. */
|
||||||
|
export const inject = ['invariants']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No runtime invariant: tool and slot registrations are effects
|
||||||
|
* owned and observed by their respective registries; the host pending table is
|
||||||
|
* exercised through the public wire protocol.
|
||||||
|
*/
|
||||||
|
const install: InvariantInstaller = () => {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register this package's invariant companion.
|
||||||
|
* @param ctx - Cordis context carrying the invariant service.
|
||||||
|
* @returns The installed registration's disposer after setup succeeds.
|
||||||
|
*/
|
||||||
|
export const apply = (ctx: Context): Promise<() => void> =>
|
||||||
|
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||||
|
/* jscpd:ignore-end */
|
||||||
58
packages/client/ui-question/tests/browser-plugin.spec.ts
Normal file
58
packages/client/ui-question/tests/browser-plugin.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Context } from 'cordis'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
|
import type { PendingInteraction, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import { apply, inject } from '../src/client/index.ts'
|
||||||
|
|
||||||
|
type QuestionInteraction = Extract<PendingInteraction, { kind: 'question' }>
|
||||||
|
|
||||||
|
function interaction(): QuestionInteraction {
|
||||||
|
return {
|
||||||
|
kind: 'question', rpcId: RpcId('question-1'),
|
||||||
|
questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ui-question browser plugin', () => {
|
||||||
|
it('declares its services and fails loud without them', () => {
|
||||||
|
expect(inject).toEqual(['slots', 'sessions'])
|
||||||
|
expect(() => { apply(new Context()) }).toThrow(/slots and sessions services are required/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers scoped answer and cancel actions, including rejected receipts', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SlotsService).await()
|
||||||
|
const answerQuestion = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ accepted: true })
|
||||||
|
.mockResolvedValueOnce({ accepted: false, reason: 'not-pending' })
|
||||||
|
const cancelQuestion = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ accepted: true })
|
||||||
|
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
|
||||||
|
ctx.provide('sessions', {
|
||||||
|
manager: { get: vi.fn(() => ({ answerQuestion, cancelQuestion })) },
|
||||||
|
})
|
||||||
|
const slots = ctx.get('slots') as SlotsService
|
||||||
|
slots.define('conversation.composer', { kind: 'keyed', scope: 'session' })
|
||||||
|
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||||
|
await fiber.await()
|
||||||
|
|
||||||
|
const entry = slots.entries('conversation.composer')[0] as unknown as {
|
||||||
|
options: { inject(binding: { sessionId: SessionId }): { actions: {
|
||||||
|
answer: (item: QuestionInteraction, answer: { answers: { id: string; selected: string[] }[] }) => Promise<void>
|
||||||
|
cancel: (item: QuestionInteraction) => Promise<void>
|
||||||
|
} } }
|
||||||
|
}
|
||||||
|
const actions = entry.options.inject({ sessionId: 'session-1' as SessionId }).actions
|
||||||
|
const item = interaction()
|
||||||
|
const answer = { answers: [{ id: 'mode', selected: ['Fast'] }] }
|
||||||
|
|
||||||
|
await expect(actions.answer(item, answer)).resolves.toBeUndefined()
|
||||||
|
await expect(actions.answer(item, answer)).rejects.toThrow(/not-pending/)
|
||||||
|
await expect(actions.cancel(item)).resolves.toBeUndefined()
|
||||||
|
await expect(actions.cancel(item)).rejects.toThrow(/bad-response/)
|
||||||
|
expect(answerQuestion).toHaveBeenCalledWith(item.rpcId, answer)
|
||||||
|
expect(cancelQuestion).toHaveBeenCalledWith(item.rpcId)
|
||||||
|
await ctx.fiber.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
28
packages/client/ui-question/tests/node-plugin.spec.ts
Normal file
28
packages/client/ui-question/tests/node-plugin.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Context } from 'cordis'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||||
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
|
import { apply, inject } from '../src/index.ts'
|
||||||
|
|
||||||
|
let ctx: Context | undefined
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx?.fiber.dispose()
|
||||||
|
ctx = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ui-question node plugin', () => {
|
||||||
|
it('exposes ask_user_question only for the selected Web feature lifecycle', async () => {
|
||||||
|
ctx = new Context()
|
||||||
|
await ctx.plugin(SystemPrompt)
|
||||||
|
await ctx.plugin(ToolRegistry)
|
||||||
|
await ctx.plugin(UserInteractionService)
|
||||||
|
const feature = ctx.plugin({ inject: [...inject], apply })
|
||||||
|
await feature.await()
|
||||||
|
expect(ctx.tools.get('ask_user_question')).toBeDefined()
|
||||||
|
|
||||||
|
await feature.dispose()
|
||||||
|
expect(ctx.tools.get('ask_user_question')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
198
packages/client/ui-question/tests/question-composer.spec.tsx
Normal file
198
packages/client/ui-question/tests/question-composer.spec.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
|
import {
|
||||||
|
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
|
||||||
|
} from '../src/client/QuestionComposer.tsx'
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
type Interaction = Extract<PendingInteraction, { kind: 'question' }>
|
||||||
|
|
||||||
|
function interaction(rpcId = 'question-1'): Interaction {
|
||||||
|
return {
|
||||||
|
kind: 'question',
|
||||||
|
rpcId: RpcId(rpcId),
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: 'profile', header: '偏好', question: '选择候选人类型',
|
||||||
|
options: [
|
||||||
|
{ label: '工程落地型 (Recommended)', description: '优先工程交付。' },
|
||||||
|
{ label: '研究潜力型', description: '优先研究能力。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'detail', question: '补充你的要求',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'signals', question: '选择重要信号(可多选)', multiSelect: true,
|
||||||
|
options: [{ label: '系统设计' }, { label: '代码质量' }, { label: '产品判断' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('QuestionComposer', () => {
|
||||||
|
it('collects single, custom, and multi-select answers before one batch submit', () => {
|
||||||
|
const answer = vi.fn(() => Promise.resolve())
|
||||||
|
const cancel = vi.fn(() => Promise.resolve())
|
||||||
|
render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('1 / 3')).toBeTruthy()
|
||||||
|
expect(screen.getByText('推荐')).toBeTruthy()
|
||||||
|
expect(screen.getByText('工程落地型')).toBeTruthy()
|
||||||
|
fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' })
|
||||||
|
expect(answer).not.toHaveBeenCalled()
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
|
||||||
|
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
expect(screen.queryByRole('button', { name: '填写答案' })).toBeNull()
|
||||||
|
const custom = screen.getByPlaceholderText('输入你的答案')
|
||||||
|
fireEvent.change(custom, { target: { value: '要能独立排查线上问题' } })
|
||||||
|
fireEvent.keyDown(custom, { key: 'Enter' })
|
||||||
|
|
||||||
|
expect(screen.getByText('3 / 3')).toBeTruthy()
|
||||||
|
expect(screen.getByText('选择重要信号')).toBeTruthy()
|
||||||
|
expect(screen.getByText('可多选')).toBeTruthy()
|
||||||
|
expect(screen.queryByText('(可多选)')).toBeNull()
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
|
||||||
|
fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' })
|
||||||
|
|
||||||
|
expect(answer).toHaveBeenCalledWith(interaction(), {
|
||||||
|
answers: [
|
||||||
|
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
|
||||||
|
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
|
||||||
|
{ id: 'signals', selected: ['系统设计', '代码质量'] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips individual questions without discarding earlier answers', () => {
|
||||||
|
const answer = vi.fn(() => Promise.resolve())
|
||||||
|
const cancel = vi.fn(() => Promise.resolve())
|
||||||
|
render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
|
||||||
|
|
||||||
|
expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
|
||||||
|
expect(screen.getByText('3 / 3')).toBeTruthy()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '跳过本题' }))
|
||||||
|
|
||||||
|
expect(cancel).not.toHaveBeenCalled()
|
||||||
|
expect(answer).toHaveBeenCalledWith(interaction(), {
|
||||||
|
answers: [
|
||||||
|
{ id: 'profile', selected: ['研究潜力型'] },
|
||||||
|
{ id: 'detail', selected: [] },
|
||||||
|
{ id: 'signals', selected: [] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps IME Enter inside the custom input until composition finishes', () => {
|
||||||
|
const answer = vi.fn(() => Promise.resolve())
|
||||||
|
const cancel = vi.fn(() => Promise.resolve())
|
||||||
|
render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' }))
|
||||||
|
const custom = screen.getByPlaceholderText('输入你的答案')
|
||||||
|
fireEvent.change(custom, { target: { value: '中文输入' } })
|
||||||
|
|
||||||
|
fireEvent.keyDown(custom, { key: 'Enter', isComposing: true })
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
expect(answer).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 })
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
expect(answer).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
fireEvent.keyDown(custom, { key: 'Enter' })
|
||||||
|
expect(screen.getByText('3 / 3')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens custom input, reports missing skipped answers, and supports header navigation', () => {
|
||||||
|
const answer = vi.fn(() => Promise.resolve())
|
||||||
|
const cancel = vi.fn(() => Promise.resolve())
|
||||||
|
render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' }))
|
||||||
|
expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
|
||||||
|
const emptyCustom = screen.getByPlaceholderText('输入你的答案')
|
||||||
|
fireEvent.keyDown(emptyCustom, { key: 'Enter', shiftKey: true })
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
fireEvent.keyDown(emptyCustom, { key: 'Enter' })
|
||||||
|
expect(screen.getByText('请选择一个选项或填写自定义答案。')).toBeTruthy()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('下一题'))
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||||
|
expect(screen.getByText('请先完成这道问题。')).toBeTruthy()
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
fireEvent.click(screen.getByLabelText('上一题'))
|
||||||
|
expect(screen.getByText('1 / 3')).toBeTruthy()
|
||||||
|
expect(answer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces explicit cancellation rejection', async () => {
|
||||||
|
const answer = vi.fn(() => Promise.resolve())
|
||||||
|
const cancel = vi.fn(() => Promise.reject('取消请求失败'))
|
||||||
|
render(<QuestionComposer interaction={interaction()} actions={{ answer, cancel }} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||||
|
expect(await screen.findByText('取消请求失败')).toBeTruthy()
|
||||||
|
expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false)
|
||||||
|
|
||||||
|
cancel.mockRejectedValueOnce(new Error('第二次取消失败'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' }))
|
||||||
|
expect(await screen.findByText('第二次取消失败')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces transport rejection and resets local drafts for a different rpcId', async () => {
|
||||||
|
const answer = vi.fn(() => Promise.reject(new Error('网络中断')))
|
||||||
|
const cancel = vi.fn(() => Promise.resolve())
|
||||||
|
const first = interaction('first')
|
||||||
|
const view = render(<QuestionComposer interaction={first} actions={{ answer, cancel }} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ }))
|
||||||
|
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||||
|
view.rerender(<QuestionComposer interaction={interaction('second')} actions={{ answer, cancel }} />)
|
||||||
|
expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
|
||||||
|
const custom = screen.getByPlaceholderText('输入你的答案')
|
||||||
|
fireEvent.change(custom, { target: { value: 'x' } })
|
||||||
|
fireEvent.keyDown(custom, { key: 'Enter' })
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||||
|
expect(await screen.findByText('网络中断')).toBeTruthy()
|
||||||
|
expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false)
|
||||||
|
|
||||||
|
answer.mockRejectedValueOnce('字符串错误')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||||
|
expect(await screen.findByText('字符串错误')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseRecommendedLabel', () => {
|
||||||
|
it('recognizes English and Chinese suffixes without changing ordinary labels', () => {
|
||||||
|
expect(parseRecommendedLabel('Fast (Recommended)')).toEqual({ label: 'Fast', recommended: true })
|
||||||
|
expect(parseRecommendedLabel('稳妥(推荐)')).toEqual({ label: '稳妥', recommended: true })
|
||||||
|
expect(parseRecommendedLabel('稳妥 (推荐)')).toEqual({ label: '稳妥', recommended: true })
|
||||||
|
expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseQuestionTitle', () => {
|
||||||
|
it('removes Chinese and ASCII multi-select suffixes', () => {
|
||||||
|
expect(parseQuestionTitle('选择信号(可多选)')).toBe('选择信号')
|
||||||
|
expect(parseQuestionTitle('选择信号 (可多选)')).toBe('选择信号')
|
||||||
|
expect(parseQuestionTitle('选择信号')).toBe('选择信号')
|
||||||
|
})
|
||||||
|
})
|
||||||
43
packages/client/ui-question/tsconfig.json
Normal file
43
packages/client/ui-question/tsconfig.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "lib/types",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": [
|
||||||
|
"ES2024",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../../../vendor/cordis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../runtime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../ui-conversation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../ui-primitives"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../ui-slots"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../web-react"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../ui/tool-ask-user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../support/invariants"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
3
packages/client/ui-question/tsdown.config.ts
Normal file
3
packages/client/ui-question/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { clientBundle } from '../tsdown.client.ts'
|
||||||
|
|
||||||
|
export default clientBundle('@deepseek-ai/dsh-client-ui-question', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||||
@@ -14,13 +14,16 @@ import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-clie
|
|||||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||||
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { ConversationRoot, ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import { ConversationRoot, ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import type { ConvViewProps, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type { ConversationInjected, ConvViewProps, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import {
|
import {
|
||||||
apply, deriveSpans, deriveSpanStats, inject, TrajectoryStatsHeader, TrajectoryView, WaterfallView,
|
apply, deriveSpans, deriveSpanStats, inject, TrajectoryStatsHeader, TrajectoryView, WaterfallView,
|
||||||
} from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
} from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
||||||
|
|
||||||
const SID = 's1' as SessionId
|
const SID = 's1' as SessionId
|
||||||
|
const fallbackSlots: ConversationInjected['slots'] = {
|
||||||
|
renderSlot: (_key, _props, opts) => opts?.fallback ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
@@ -70,8 +73,14 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] =
|
|||||||
}
|
}
|
||||||
return createElement(Fragment, null, children)
|
return createElement(Fragment, null, children)
|
||||||
}
|
}
|
||||||
const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
|
const sessionSnapshot = createSnapshotStore<{
|
||||||
running: false, removed: false, promptError: null, nodes,
|
running: boolean
|
||||||
|
removed: boolean
|
||||||
|
promptError: null
|
||||||
|
nodes: ConversationSnapshot['nodes']
|
||||||
|
pending: ConversationSnapshot['pending']
|
||||||
|
}>({
|
||||||
|
running: false, removed: false, promptError: null, nodes, pending: [],
|
||||||
})
|
})
|
||||||
return render(
|
return render(
|
||||||
<ConversationRoot
|
<ConversationRoot
|
||||||
@@ -87,6 +96,7 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] =
|
|||||||
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
|
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
|
||||||
actions={{ openView: ((v: string) => { activeStore.set(v) }) as (v: never) => void, open: vi.fn() }}
|
actions={{ openView: ((v: string) => { activeStore.set(v) }) as (v: never) => void, open: vi.fn() }}
|
||||||
renderView={renderView}
|
renderView={renderView}
|
||||||
|
slots={fallbackSlots}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
|
|||||||
/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
|
/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
|
||||||
export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
|
export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
|
||||||
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
|
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
|
||||||
|
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
|
||||||
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function RpcId(id: string): RpcId {
|
|||||||
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
|
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
|
||||||
export interface RpcErrorDetailsMap {
|
export interface RpcErrorDetailsMap {
|
||||||
'bad-request': { issues: ZodIssue[] }
|
'bad-request': { issues: ZodIssue[] }
|
||||||
|
'cancelled': {}
|
||||||
'session-not-found': { sessionId: SessionId }
|
'session-not-found': { sessionId: SessionId }
|
||||||
'agent-busy': { reason: string }
|
'agent-busy': { reason: string }
|
||||||
'internal': {}
|
'internal': {}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('RpcId', () => {
|
|||||||
describe('rpcErrorSchema', () => {
|
describe('rpcErrorSchema', () => {
|
||||||
it('accepts every code branch with its required details', () => {
|
it('accepts every code branch with its required details', () => {
|
||||||
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
|
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
|
||||||
|
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
|
||||||
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
|
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
|
||||||
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
|
||||||
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# @deepseek-ai/dsh-host-runtime
|
# @deepseek-ai/dsh-host-runtime
|
||||||
|
|
||||||
Host runtime assembly for `dsc`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
|
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
|
||||||
|
|
||||||
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
|
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ Which plugins mount and with what defaults is decided only here — shells must
|
|||||||
|
|
||||||
## ApiProxy implementation notes
|
## ApiProxy implementation notes
|
||||||
|
|
||||||
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session on open; the host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
|
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
|
||||||
|
|
||||||
## Model Experience
|
## Model Experience
|
||||||
|
|
||||||
@@ -26,6 +26,6 @@ No direct invalidation; the mounted model-facing plugins own their request-prefi
|
|||||||
|
|
||||||
## Known Limitations and Deferred Work
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
- **`respond` is a stub** — it always returns `not-pending`; the approval/question pending registry (stable-rpcId mint on accept, baseline replay on stream reopen, wire answerer) is the next host-side step.
|
- **Question waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence.
|
||||||
- **`session.list` covers live sessions only** — cold sessions in the persistence directory are not yet merged into the listing; `host.describe.version` is a placeholder rather than the `apps/cli` package version.
|
- **`host.describe.version` is a placeholder** — it does not yet read the `apps/cli` package version.
|
||||||
- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.
|
- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"@deepseek-ai/dsh-client-i18n": "workspace:^",
|
"@deepseek-ai/dsh-client-i18n": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||||
|
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
|
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Host-side ApiProxy implementation (minimal-first —
|
* Host-side ApiProxy implementation. Signature discipline: unary takes the
|
||||||
* describe/list/create/history/prompt/cancel and both streams are real,
|
* narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
|
||||||
* respond is a stub). Signature discipline: unary takes the narrow
|
|
||||||
* RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
@@ -12,9 +10,16 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
|||||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||||
import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
|
import type {
|
||||||
|
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
|
||||||
|
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||||
|
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
|
||||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
|
import type {
|
||||||
|
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
|
||||||
|
} from '@deepseek-ai/dsh-user-interaction'
|
||||||
|
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||||
|
|
||||||
/** Page size when history is called without maxMessages. */
|
/** Page size when history is called without maxMessages. */
|
||||||
const DEFAULT_MAX_MESSAGES = 50
|
const DEFAULT_MAX_MESSAGES = 50
|
||||||
@@ -155,6 +160,35 @@ interface ToolCallData { callId: string; name: string; arguments: string }
|
|||||||
/** The tool/result payload fields the presenter path reads. */
|
/** The tool/result payload fields the presenter path reads. */
|
||||||
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: unknown }
|
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: unknown }
|
||||||
|
|
||||||
|
/** One host-owned question wait, addressed by the stable server-request id. */
|
||||||
|
interface PendingQuestion {
|
||||||
|
rpcId: RpcId
|
||||||
|
sessionId: SessionId
|
||||||
|
questions: AskUserQuestionItem[]
|
||||||
|
resolve: (answer: AskUserQuestionAnswer) => void
|
||||||
|
reject: (error: UserInteractionError) => void
|
||||||
|
signal?: AbortSignal
|
||||||
|
onAbort?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate one answer batch against the exact question request it resolves. */
|
||||||
|
function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
|
||||||
|
if (payload.sessionId !== pending.sessionId) return false
|
||||||
|
const answers = payload.answer.answers
|
||||||
|
if (answers.length !== pending.questions.length) return false
|
||||||
|
return answers.every((answer, index) => {
|
||||||
|
const question = pending.questions[index] as AskUserQuestionItem
|
||||||
|
if (answer.id !== question.id) return false
|
||||||
|
if (new Set(answer.selected).size !== answer.selected.length) return false
|
||||||
|
const custom = answer.custom?.trim()
|
||||||
|
if (custom !== undefined && custom === '') return false
|
||||||
|
if (custom !== undefined && answer.selected.length > 0) return false
|
||||||
|
if (question.multiSelect !== true && answer.selected.length > 1) return false
|
||||||
|
const labels = new Set(question.options?.map(option => option.label) ?? [])
|
||||||
|
return answer.selected.every(label => labels.has(label))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the render intent for a tool/call or tool/result event through the
|
* Compute the render intent for a tool/call or tool/result event through the
|
||||||
* presenters registered at this moment; every other event type gets none. A
|
* presenters registered at this moment; every other event type gets none. A
|
||||||
@@ -219,12 +253,70 @@ class SessionNotFound extends Error {}
|
|||||||
* @param ctx - the root context returned by bootHost (sessions/agents services mounted).
|
* @param ctx - the root context returned by bootHost (sessions/agents services mounted).
|
||||||
* @param defaults - host-level default provider/model: injected as
|
* @param defaults - host-level default provider/model: injected as
|
||||||
* agentOptions on create/resume, reported by describe from the same source.
|
* agentOptions on create/resume, reported by describe from the same source.
|
||||||
* @returns the ApiProxy implementation (minimal-first; stubs noted per method).
|
* @returns the ApiProxy implementation.
|
||||||
*/
|
*/
|
||||||
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
||||||
const agentOptions = { provider: defaults.provider, model: defaults.model }
|
const agentOptions = { provider: defaults.provider, model: defaults.model }
|
||||||
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
||||||
const resumes = new Map<SessionId, Promise<Agent>>()
|
const resumes = new Map<SessionId, Promise<Agent>>()
|
||||||
|
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
||||||
|
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
||||||
|
|
||||||
|
/** Send one transient frame to every connected mux consumer. */
|
||||||
|
function broadcast(payload: MuxFrame): void {
|
||||||
|
const envelope = frame(payload)
|
||||||
|
for (const queue of muxQueues) queue.push(envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
|
||||||
|
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
|
||||||
|
pendingQuestions.delete(pending.rpcId)
|
||||||
|
if (pending.signal !== undefined && pending.onAbort !== undefined) {
|
||||||
|
pending.signal.removeEventListener('abort', pending.onAbort)
|
||||||
|
}
|
||||||
|
broadcast({
|
||||||
|
type: 'question/resolved', sessionId: pending.sessionId,
|
||||||
|
questionRpcId: pending.rpcId, outcome,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const disposeProvider = ctx.userInteraction.registerProvider({
|
||||||
|
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
||||||
|
const sessionId = request.agent?.id
|
||||||
|
if (sessionId === undefined) {
|
||||||
|
return Promise.reject(new UserInteractionError(
|
||||||
|
'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
|
||||||
|
}
|
||||||
|
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
|
||||||
|
const rpcId = RpcId(randomUUID())
|
||||||
|
const pending: PendingQuestion = {
|
||||||
|
rpcId, sessionId, questions: request.questions, resolve, reject,
|
||||||
|
...(request.signal === undefined ? {} : { signal: request.signal }),
|
||||||
|
}
|
||||||
|
const onAbort = (): void => {
|
||||||
|
claimQuestion(pending, 'cancelled')
|
||||||
|
reject(new UserInteractionError(
|
||||||
|
'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
|
||||||
|
}
|
||||||
|
pending.onAbort = onAbort
|
||||||
|
pendingQuestions.set(rpcId, pending)
|
||||||
|
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||||
|
const envelope: RpcRequest<MuxFrame> = {
|
||||||
|
rpcId,
|
||||||
|
payload: { type: 'question/requested', sessionId, questions: request.questions },
|
||||||
|
}
|
||||||
|
for (const queue of muxQueues) queue.push(envelope)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ctx.effect(() => () => {
|
||||||
|
disposeProvider()
|
||||||
|
for (const pending of [...pendingQuestions.values()]) {
|
||||||
|
claimQuestion(pending, 'cancelled')
|
||||||
|
pending.reject(new UserInteractionError(
|
||||||
|
'web user-interaction provider was disposed', 'ASK_ABORTED'))
|
||||||
|
}
|
||||||
|
}, 'api-proxy: user-interaction provider')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate the cold path on the store: an id absent from it, or naming a legacy
|
* Gate the cold path on the store: an id absent from it, or naming a legacy
|
||||||
@@ -361,9 +453,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
events: {
|
events: {
|
||||||
mux(_request, signal) {
|
mux(_request, signal) {
|
||||||
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
|
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
|
||||||
|
muxQueues.add(queue)
|
||||||
for (const session of ctx.sessions.list()) {
|
for (const session of ctx.sessions.list()) {
|
||||||
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
||||||
}
|
}
|
||||||
|
for (const pending of pendingQuestions.values()) {
|
||||||
|
queue.push({
|
||||||
|
rpcId: pending.rpcId,
|
||||||
|
payload: {
|
||||||
|
type: 'question/requested', sessionId: pending.sessionId,
|
||||||
|
questions: pending.questions,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
// Per-session open-call table for result-view pairing. Bounded by the
|
// Per-session open-call table for result-view pairing. Bounded by the
|
||||||
// per-turn call count: entries clear on turn/end; a table miss (stream
|
// per-turn call count: entries clear on turn/end; a table miss (stream
|
||||||
// opened mid-turn) backscans the session's in-memory events instead.
|
// opened mid-turn) backscans the session's in-memory events instead.
|
||||||
@@ -393,7 +495,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
openCalls.delete(session.id)
|
openCalls.delete(session.id)
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
return queue.iterate(signal, () => {
|
||||||
|
muxQueues.delete(queue)
|
||||||
|
for (const dispose of disposers) dispose()
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
host(_request, signal) {
|
host(_request, signal) {
|
||||||
@@ -421,9 +526,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// TODO(step2): approval/question pending registry (wire answerer + proxy provider).
|
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||||
respond(_message: ClientResponse): Promise<RpcReceipt> {
|
const pending = pendingQuestions.get(message.rpcId)
|
||||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||||
|
if (!message.result.ok) {
|
||||||
|
if (message.result.error.code !== 'cancelled') {
|
||||||
|
return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||||
|
}
|
||||||
|
claimQuestion(pending, 'cancelled')
|
||||||
|
pending.reject(new UserInteractionError(
|
||||||
|
'the user cancelled ask_user_question', 'ASK_CANCELLED'))
|
||||||
|
return Promise.resolve({ accepted: true })
|
||||||
|
}
|
||||||
|
const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||||
|
}
|
||||||
|
const payload: QuestionResponsePayload = {
|
||||||
|
sessionId: parsed.data.sessionId,
|
||||||
|
answer: {
|
||||||
|
answers: parsed.data.answer.answers.map(answer => ({
|
||||||
|
id: answer.id,
|
||||||
|
selected: answer.selected,
|
||||||
|
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (!matchesQuestions(payload, pending)) {
|
||||||
|
return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||||
|
}
|
||||||
|
claimQuestion(pending, 'answered')
|
||||||
|
pending.resolve(payload.answer)
|
||||||
|
return Promise.resolve({ accepted: true })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
|||||||
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
||||||
import SpillLocal from '@deepseek-ai/dsh-spill-local'
|
import SpillLocal from '@deepseek-ai/dsh-spill-local'
|
||||||
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
|
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
|
||||||
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
|
|
||||||
/** Options for bootHost — the assembly-layer composition knobs. */
|
/** Options for bootHost — the assembly-layer composition knobs. */
|
||||||
export interface BootHostOptions {
|
export interface BootHostOptions {
|
||||||
@@ -91,6 +92,7 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
|
|||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
|
await ctx.plugin(UserInteractionService)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
await ctx.plugin(TaskService)
|
await ctx.plugin(TaskService)
|
||||||
await ctx.plugin(AgentLoop, { agents: [] })
|
await ctx.plugin(AgentLoop, { agents: [] })
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
|
* Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
|
||||||
* entry tree listing the eight UI plugin packages (the P-I config-source bar —
|
* entry tree listing the nine UI plugin packages (the P-I config-source bar —
|
||||||
* a cordis.yml file form comes later; install/remove currently means editing
|
* a cordis.yml file form comes later; install/remove currently means editing
|
||||||
* this list and restarting). The web plugin registry discovers the entries by
|
* this list and restarting). The web plugin registry discovers the entries by
|
||||||
* their package.json dshClient declarations; node halves are empty applies,
|
* their package.json dshClient declarations; feature packages may also mount
|
||||||
* so mounting them here costs nothing beyond Loader governance.
|
* their interface-specific host half through the same lifecycle.
|
||||||
*/
|
*/
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
|
|
||||||
/** The eight UI plugin packages served to the browser (order = manifest order). */
|
/** The nine UI plugin packages served to the browser (order = manifest order). */
|
||||||
export const WEB_UI_PLUGINS = [
|
export const WEB_UI_PLUGINS = [
|
||||||
'@deepseek-ai/dsh-client-connection',
|
'@deepseek-ai/dsh-client-connection',
|
||||||
'@deepseek-ai/dsh-client-runtime',
|
'@deepseek-ai/dsh-client-runtime',
|
||||||
@@ -19,6 +19,7 @@ export const WEB_UI_PLUGINS = [
|
|||||||
'@deepseek-ai/dsh-client-ui-layout',
|
'@deepseek-ai/dsh-client-ui-layout',
|
||||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||||
'@deepseek-ai/dsh-client-ui-conversation',
|
'@deepseek-ai/dsh-client-ui-conversation',
|
||||||
|
'@deepseek-ai/dsh-client-ui-question',
|
||||||
'@deepseek-ai/dsh-client-ui-trajectory',
|
'@deepseek-ai/dsh-client-ui-trajectory',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export interface MountedWebPlugins {
|
|||||||
export async function mountWebPlugins(ctx: Context): Promise<MountedWebPlugins> {
|
export async function mountWebPlugins(ctx: Context): Promise<MountedWebPlugins> {
|
||||||
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
|
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
|
||||||
// import silently fails and every entry stays fiber-less. This package
|
// import silently fails and every entry stays fiber-less. This package
|
||||||
// depends on all eight UI plugins, so its own URL is the right anchor.
|
// depends on all nine UI plugins, so its own URL is the right anchor.
|
||||||
ctx.baseUrl ??= import.meta.url
|
ctx.baseUrl ??= import.meta.url
|
||||||
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
|
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
|
||||||
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
|
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import SessionStore from '@deepseek-ai/dsh-session'
|
import SessionStore from '@deepseek-ai/dsh-session'
|
||||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||||
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
@@ -32,6 +33,7 @@ describe('sessions.list cold merge', () => {
|
|||||||
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
|
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
|
await ctx.plugin(UserInteractionService)
|
||||||
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
|
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
|
||||||
const logPath = join(root, 'a.log')
|
const logPath = join(root, 'a.log')
|
||||||
writeFileSync(logPath, 'log-bytes')
|
writeFileSync(logPath, 'log-bytes')
|
||||||
@@ -76,6 +78,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
|||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
|
await ctx.plugin(UserInteractionService)
|
||||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||||
|
|
||||||
const listed = await api.sessions.list(request({}))
|
const listed = await api.sessions.list(request({}))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|||||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||||
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||||
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
import { createApiProxy } from '../src/api-proxy.ts'
|
import { createApiProxy } from '../src/api-proxy.ts'
|
||||||
@@ -38,6 +39,7 @@ async function harness(): Promise<{ ctx: Context }> {
|
|||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
|
await ctx.plugin(UserInteractionService)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
ctx.tools.register(tool('gen', {
|
ctx.tools.register(tool('gen', {
|
||||||
presentCall: () => ({ card: 'generic', title: 'gen call' }),
|
presentCall: () => ({ card: 'generic', title: 'gen call' }),
|
||||||
|
|||||||
@@ -358,10 +358,175 @@ describe('events streams', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('respond stub', () => {
|
describe('question request / response', () => {
|
||||||
it('always reports not-pending (step2 registry pending)', async () => {
|
const questions = [{
|
||||||
const { api } = await boot()
|
id: 'mode', question: 'Choose a mode',
|
||||||
const receipt = await api.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })
|
options: [
|
||||||
expect(receipt).toEqual({ accepted: false, reason: 'not-pending' })
|
{ label: 'Fast (Recommended)', description: 'Move quickly.' },
|
||||||
|
{ label: 'Careful', description: 'Review first.' },
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
|
||||||
|
it('waits, replays the same rpcId on reconnect, validates, and resolves first-wins', async () => {
|
||||||
|
const running = await boot()
|
||||||
|
const { api, ctx } = running
|
||||||
|
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||||
|
const agent = ctx.agents.get(sessionId) as Agent
|
||||||
|
const ac = new AbortController()
|
||||||
|
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||||
|
await stream.next() // subscribed baseline starts the generator and installs the queue
|
||||||
|
|
||||||
|
const answerPromise = ctx.userInteraction.ask({ questions, agent })
|
||||||
|
const requested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(requested.payload).toMatchObject({ type: 'question/requested', sessionId, questions })
|
||||||
|
|
||||||
|
const wrongSession = await api.respond({
|
||||||
|
type: 'client-response', rpcId: requested.rpcId,
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
value: { sessionId: 'session-other', answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(wrongSession).toEqual({ accepted: false, reason: 'bad-response' })
|
||||||
|
const badChoice = await api.respond({
|
||||||
|
type: 'client-response', rpcId: requested.rpcId,
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Unknown'] }] } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(badChoice).toEqual({ accepted: false, reason: 'bad-response' })
|
||||||
|
const invalidResults = [
|
||||||
|
{ ok: true as const, value: null },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [] } } },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'wrong', selected: ['Fast (Recommended)'] }] } } },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Fast (Recommended)'] }] } } },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Careful'] }] } } },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: [], custom: ' ' }] } } },
|
||||||
|
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Careful'], custom: 'Other' }] } } },
|
||||||
|
{ ok: false as const, error: { code: 'internal' as const, message: 'wrong error', details: {} } },
|
||||||
|
]
|
||||||
|
for (const result of invalidResults) {
|
||||||
|
expect(await api.respond({
|
||||||
|
type: 'client-response', rpcId: requested.rpcId, result,
|
||||||
|
})).toEqual({ accepted: false, reason: 'bad-response' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconnectAbort = new AbortController()
|
||||||
|
const replay = api.events.mux(request({}), reconnectAbort.signal)[Symbol.asyncIterator]()
|
||||||
|
await replay.next()
|
||||||
|
const replayed = (await replay.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(replayed.rpcId).toBe(requested.rpcId)
|
||||||
|
expect(replayed.payload).toEqual(requested.payload)
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
type: 'client-response' as const,
|
||||||
|
rpcId: requested.rpcId,
|
||||||
|
result: {
|
||||||
|
ok: true as const,
|
||||||
|
value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const [first, duplicate] = await Promise.all([api.respond(response), api.respond(response)])
|
||||||
|
expect([first, duplicate]).toContainEqual({ accepted: true })
|
||||||
|
expect([first, duplicate]).toContainEqual({ accepted: false, reason: 'not-pending' })
|
||||||
|
await expect(answerPromise).resolves.toEqual({
|
||||||
|
answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolved = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(resolved.payload).toMatchObject({
|
||||||
|
type: 'question/resolved', sessionId, questionRpcId: requested.rpcId, outcome: 'answered',
|
||||||
|
})
|
||||||
|
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
|
||||||
|
|
||||||
|
const customQuestions = [{ id: 'detail', question: 'What else?' }]
|
||||||
|
const customAnswer = ctx.userInteraction.ask({ questions: customQuestions, agent })
|
||||||
|
const customRequested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(await api.respond({
|
||||||
|
type: 'client-response', rpcId: customRequested.rpcId,
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
value: { sessionId, answer: { answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }] } },
|
||||||
|
},
|
||||||
|
})).toEqual({ accepted: true })
|
||||||
|
await expect(customAnswer).resolves.toEqual({
|
||||||
|
answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }],
|
||||||
|
})
|
||||||
|
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||||
|
type: 'question/resolved', questionRpcId: customRequested.rpcId, outcome: 'answered',
|
||||||
|
})
|
||||||
|
|
||||||
|
const blankAnswer = ctx.userInteraction.ask({ questions, agent })
|
||||||
|
const blankRequested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(await api.respond({
|
||||||
|
type: 'client-response', rpcId: blankRequested.rpcId,
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
value: { sessionId, answer: { answers: [{ id: 'mode', selected: [] }] } },
|
||||||
|
},
|
||||||
|
})).toEqual({ accepted: true })
|
||||||
|
await expect(blankAnswer).resolves.toEqual({
|
||||||
|
answers: [{ id: 'mode', selected: [] }],
|
||||||
|
})
|
||||||
|
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||||
|
type: 'question/resolved', questionRpcId: blankRequested.rpcId, outcome: 'answered',
|
||||||
|
})
|
||||||
|
ac.abort()
|
||||||
|
reconnectAbort.abort()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes user cancellation from owner abort and rejects late responses', async () => {
|
||||||
|
const running = await boot()
|
||||||
|
const { api, ctx } = running
|
||||||
|
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||||
|
const agent = ctx.agents.get(sessionId) as Agent
|
||||||
|
const streamAbort = new AbortController()
|
||||||
|
const stream = api.events.mux(request({}), streamAbort.signal)[Symbol.asyncIterator]()
|
||||||
|
await stream.next()
|
||||||
|
|
||||||
|
const cancelled = ctx.userInteraction.ask({ questions, agent }).catch((error: unknown) => error)
|
||||||
|
const requested = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
expect(await api.respond({
|
||||||
|
type: 'client-response', rpcId: requested.rpcId,
|
||||||
|
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
|
||||||
|
})).toEqual({ accepted: true })
|
||||||
|
await expect(cancelled).resolves.toMatchObject({ code: 'ASK_CANCELLED' })
|
||||||
|
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||||
|
type: 'question/resolved', outcome: 'cancelled',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ownerAbort = new AbortController()
|
||||||
|
const aborted = ctx.userInteraction.ask({ questions, agent, signal: ownerAbort.signal })
|
||||||
|
.catch((error: unknown) => error)
|
||||||
|
const abortRequest = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||||
|
ownerAbort.abort()
|
||||||
|
await expect(aborted).resolves.toMatchObject({ code: 'ASK_ABORTED' })
|
||||||
|
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
|
||||||
|
type: 'question/resolved', questionRpcId: abortRequest.rpcId, outcome: 'cancelled',
|
||||||
|
})
|
||||||
|
expect(await api.respond({
|
||||||
|
type: 'client-response', rpcId: abortRequest.rpcId,
|
||||||
|
result: { ok: false, error: { code: 'cancelled', message: 'late', details: {} } },
|
||||||
|
})).toEqual({ accepted: false, reason: 'not-pending' })
|
||||||
|
streamAbort.abort()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects missing routing and pre-abort, then aborts outstanding waits on disposal', async () => {
|
||||||
|
const running = await boot()
|
||||||
|
const { ctx } = running
|
||||||
|
await expect(ctx.userInteraction.ask({ questions })).rejects.toMatchObject({ code: 'ASK_MISSING_AGENT' })
|
||||||
|
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
|
||||||
|
const agent = ctx.agents.get(sessionId) as Agent
|
||||||
|
const alreadyAborted = new AbortController()
|
||||||
|
alreadyAborted.abort()
|
||||||
|
await expect(ctx.userInteraction.ask({ questions, agent, signal: alreadyAborted.signal }))
|
||||||
|
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||||
|
|
||||||
|
const outstanding = ctx.userInteraction.ask({ questions, agent })
|
||||||
|
const disposed = running.dispose()
|
||||||
|
host = undefined
|
||||||
|
await expect(outstanding).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||||
|
await disposed
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Web UI plugin assembly: the in-memory Loader tree mounts all eight UI
|
* Web UI plugin assembly: the in-memory Loader tree mounts all nine UI
|
||||||
* packages (node halves), and the webserver registry built over it yields the
|
* packages (node halves), and the webserver registry built over it yields the
|
||||||
* full __DSH_BOOT__ manifest — the P-I config-source bar end to end.
|
* full __DSH_BOOT__ manifest — the P-I config-source bar end to end.
|
||||||
*
|
*
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||||
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver'
|
import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver'
|
||||||
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
|
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
|
||||||
@@ -31,8 +34,16 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe.skipIf(!built)('mountWebPlugins + registry', () => {
|
describe.skipIf(!built)('mountWebPlugins + registry', () => {
|
||||||
it('mounts the eight-package in-memory Loader tree and projects the boot manifest', async () => {
|
async function rootWithHostServices(): Promise<Context> {
|
||||||
root = new Context()
|
root = new Context()
|
||||||
|
await root.plugin(SystemPrompt)
|
||||||
|
await root.plugin(ToolRegistry)
|
||||||
|
await root.plugin(UserInteractionService)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
it('mounts the nine-package in-memory Loader tree and projects the boot manifest', async () => {
|
||||||
|
root = await rootWithHostServices()
|
||||||
const mounted = await mountWebPlugins(root)
|
const mounted = await mountWebPlugins(root)
|
||||||
const registry = createHostWebPluginRegistry({
|
const registry = createHostWebPluginRegistry({
|
||||||
ctx: root,
|
ctx: root,
|
||||||
@@ -59,7 +70,7 @@ describe.skipIf(!built)('mountWebPlugins + registry', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
|
it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
|
||||||
root = new Context()
|
root = await rootWithHostServices()
|
||||||
await mountWebPlugins(root)
|
await mountWebPlugins(root)
|
||||||
const second = await mountWebPlugins(root)
|
const second = await mountWebPlugins(root)
|
||||||
// ctx.loader hands out a fresh traced proxy per access, so loader identity
|
// ctx.loader hands out a fresh traced proxy per access, so loader identity
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* mountWebPlugins unit coverage (keyless; the real eight-package walk is the
|
* mountWebPlugins unit coverage (keyless; the real nine-package walk is the
|
||||||
* built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
|
* built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
|
||||||
* creation with idempotent reuse, the fiber-less fail-loud sweep, and the
|
* creation with idempotent reuse, the fiber-less fail-loud sweep, and the
|
||||||
* resolver seam — is exercised against a stubbed loader service so it runs
|
* resolver seam — is exercised against a stubbed loader service so it runs
|
||||||
@@ -85,7 +85,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
|
|||||||
|
|
||||||
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
|
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
|
||||||
root = new Context()
|
root = new Context()
|
||||||
// Environment-dependent outcome: with built lib/ the eight imports load
|
// Environment-dependent outcome: with built lib/ the nine imports load
|
||||||
// and the mount resolves; without them every entry stays fiber-less and
|
// and the mount resolves; without them every entry stays fiber-less and
|
||||||
// the sweep throws its loud list. Either way the branch under test is the
|
// the sweep throws its loud list. Either way the branch under test is the
|
||||||
// Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
|
// Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
|
||||||
@@ -100,7 +100,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
|
|||||||
}
|
}
|
||||||
expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
|
expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
|
||||||
expect(root.get('loader') !== undefined).toBe(true)
|
expect(root.get('loader') !== undefined).toBe(true)
|
||||||
}, 30_000) // built-env run imports eight real plugin packages through the Loader
|
}, 30_000) // built-env run imports nine real plugin packages through the Loader
|
||||||
|
|
||||||
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
|
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
|
||||||
const entriesList: FakeEntry[] = []
|
const entriesList: FakeEntry[] = []
|
||||||
|
|||||||
@@ -137,6 +137,9 @@
|
|||||||
{
|
{
|
||||||
"path": "../../client/ui-conversation"
|
"path": "../../client/ui-conversation"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "../../client/ui-question"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "../../client/ui-trajectory"
|
"path": "../../client/ui-trajectory"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
|
|||||||
- `UserInteractionProvider` — UI implementation with `ask(request)`.
|
- `UserInteractionProvider` — UI implementation with `ask(request)`.
|
||||||
- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
|
- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
|
||||||
|
|
||||||
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices.
|
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
|
||||||
|
|
||||||
## Role
|
## Role
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface AskUserQuestionItem {
|
|||||||
export interface AskUserQuestionAnswerItem {
|
export interface AskUserQuestionAnswerItem {
|
||||||
/** The answered question id. */
|
/** The answered question id. */
|
||||||
id: string
|
id: string
|
||||||
/** Selected option labels. Empty when the answer is purely custom text. */
|
/** Selected option labels. Empty for custom or unanswered choices. */
|
||||||
selected: string[]
|
selected: string[]
|
||||||
/** Optional free-text "Other" answer. */
|
/** Optional free-text "Other" answer. */
|
||||||
custom?: string
|
custom?: string
|
||||||
|
|||||||
52
pnpm-lock.yaml
generated
52
pnpm-lock.yaml
generated
@@ -607,6 +607,52 @@ importers:
|
|||||||
specifier: ^4.0.0-rc.7
|
specifier: ^4.0.0-rc.7
|
||||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||||
|
|
||||||
|
packages/client/ui-question:
|
||||||
|
dependencies:
|
||||||
|
'@deepseek-ai/dsh-client-connection':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../connection
|
||||||
|
'@deepseek-ai/dsh-client-runtime':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../runtime
|
||||||
|
'@deepseek-ai/dsh-client-ui-conversation':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../ui-conversation
|
||||||
|
'@deepseek-ai/dsh-client-ui-primitives':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../ui-primitives
|
||||||
|
'@deepseek-ai/dsh-tool-ask-user':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../ui/tool-ask-user
|
||||||
|
clsx:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.1.1
|
||||||
|
react:
|
||||||
|
specifier: ^18.2.0
|
||||||
|
version: 18.3.1
|
||||||
|
devDependencies:
|
||||||
|
'@deepseek-ai/dsh-agent':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../core/agent
|
||||||
|
'@deepseek-ai/dsh-invariants':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../support/invariants
|
||||||
|
'@deepseek-ai/dsh-system-prompt':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../core/system-prompt
|
||||||
|
'@deepseek-ai/dsh-tools':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../core/tools
|
||||||
|
'@deepseek-ai/dsh-user-interaction':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../ui/user-interaction
|
||||||
|
'@types/react':
|
||||||
|
specifier: ~18.3.1
|
||||||
|
version: 18.3.31
|
||||||
|
cordis:
|
||||||
|
specifier: ^4.0.0-rc.7
|
||||||
|
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||||
|
|
||||||
packages/client/ui-sidebar:
|
packages/client/ui-sidebar:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@deepseek-ai/dsh-client-runtime':
|
'@deepseek-ai/dsh-client-runtime':
|
||||||
@@ -1899,6 +1945,9 @@ importers:
|
|||||||
'@deepseek-ai/dsh-client-ui-layout':
|
'@deepseek-ai/dsh-client-ui-layout':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../client/ui-layout
|
version: link:../../client/ui-layout
|
||||||
|
'@deepseek-ai/dsh-client-ui-question':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../client/ui-question
|
||||||
'@deepseek-ai/dsh-client-ui-sidebar':
|
'@deepseek-ai/dsh-client-ui-sidebar':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../client/ui-sidebar
|
version: link:../../client/ui-sidebar
|
||||||
@@ -1995,6 +2044,9 @@ importers:
|
|||||||
'@deepseek-ai/dsh-tools':
|
'@deepseek-ai/dsh-tools':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../core/tools
|
version: link:../../core/tools
|
||||||
|
'@deepseek-ai/dsh-user-interaction':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../ui/user-interaction
|
||||||
'@deepseek-ai/dsh-workflow-workerthread':
|
'@deepseek-ai/dsh-workflow-workerthread':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../workflow/workflow-workerthread
|
version: link:../../workflow/workflow-workerthread
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
|||||||
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
|
'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
|
||||||
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
"@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
|
"@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
|
||||||
"@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
|
"@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
|
||||||
"@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
|
"@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
|
||||||
|
"@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"],
|
||||||
"@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"],
|
"@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"],
|
||||||
"@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"],
|
"@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"],
|
||||||
"@deepseek-ai/dsh-client-i18n": ["./packages/client/i18n/src"],
|
"@deepseek-ai/dsh-client-i18n": ["./packages/client/i18n/src"],
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
{ "path": "./packages/client/ui-layout" },
|
{ "path": "./packages/client/ui-layout" },
|
||||||
{ "path": "./packages/client/ui-sidebar" },
|
{ "path": "./packages/client/ui-sidebar" },
|
||||||
{ "path": "./packages/client/ui-conversation" },
|
{ "path": "./packages/client/ui-conversation" },
|
||||||
|
{ "path": "./packages/client/ui-question" },
|
||||||
{ "path": "./packages/client/ui-trajectory" },
|
{ "path": "./packages/client/ui-trajectory" },
|
||||||
{ "path": "./packages/client/ui-theme" },
|
{ "path": "./packages/client/ui-theme" },
|
||||||
{ "path": "./packages/client/i18n" },
|
{ "path": "./packages/client/i18n" },
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
{ "path": "./packages/client/ui-layout" },
|
{ "path": "./packages/client/ui-layout" },
|
||||||
{ "path": "./packages/client/ui-sidebar" },
|
{ "path": "./packages/client/ui-sidebar" },
|
||||||
{ "path": "./packages/client/ui-conversation" },
|
{ "path": "./packages/client/ui-conversation" },
|
||||||
|
{ "path": "./packages/client/ui-question" },
|
||||||
{ "path": "./packages/client/ui-trajectory" },
|
{ "path": "./packages/client/ui-trajectory" },
|
||||||
{ "path": "./packages/client/ui-theme" },
|
{ "path": "./packages/client/ui-theme" },
|
||||||
{ "path": "./packages/client/i18n" },
|
{ "path": "./packages/client/i18n" },
|
||||||
|
|||||||
Reference in New Issue
Block a user