Revert "feat: reconcile timed questions with legacy default"
This commit is contained in:
1 parent
6e45fd5bea
commit
32905d5ab5
101 files changed
+40579
-59074
No files matched your search
+19
-19
@@ -1,43 +1,43 @@
|
||||
# Bilingual-pair consistency record for 2026-09-19-timed-user-question-two-settlements.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-timed-user-question-two-settlements.md
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-19-timed-user-question-two-settlements.md
|
||||
/agent-note-timed-user-questions-as-two-settlements:
|
||||
en: 5b6fa73158a11213
|
||||
zh: b55c32342d6e6692
|
||||
en: 15c55d3b120b8fd4
|
||||
zh: 6b86ac34f4a1e9cd
|
||||
/agent-note-timed-user-questions-as-two-settlements/problem:
|
||||
en: f665127a9b5b8225
|
||||
zh: b19845f62310f3a2
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision:
|
||||
en: ce869bf2eed04e81
|
||||
zh: 5b1fcf44c44c6137
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/modes-and-scope:
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal:
|
||||
en: 56ca8c0f97976358
|
||||
zh: 69163bb5f064e069
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/modes-and-scope:
|
||||
en: d23e622ee4643b48
|
||||
zh: 3732c10c12344755
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/ownership:
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/ownership:
|
||||
en: 0543678ad9d26fc5
|
||||
zh: aa6ba207e661c567
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/foreground-wait-and-claim-lifetime:
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/foreground-wait-and-claim-lifetime:
|
||||
en: 447713bbdf1ab4d8
|
||||
zh: 2757678ae47822aa
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/durable-state-and-late-answers:
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/durable-state-and-late-answers:
|
||||
en: b8729d06ab7814ed
|
||||
zh: acaa8f4174701248
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/card-state-and-delivery-races:
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/card-state-and-delivery-races:
|
||||
en: 8c55b693e7a5bca4
|
||||
zh: aea6e966ce3667e3
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/local-countdown-and-panel-actions:
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/local-countdown-and-panel-actions:
|
||||
en: 4c0c9ec59fc51217
|
||||
zh: 322870eeb49176c7
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/reply-node-and-grouping:
|
||||
/agent-note-timed-user-questions-as-two-settlements/proposal/reply-node-and-grouping:
|
||||
en: 10323b0e0340a40b
|
||||
zh: a17fc3a4d4d820a7
|
||||
/agent-note-timed-user-questions-as-two-settlements/alternatives-considered:
|
||||
en: ce8ff9d5bc4b85b7
|
||||
zh: 49024fe3a5b194b2
|
||||
/agent-note-timed-user-questions-as-two-settlements/verification:
|
||||
en: bd768c23270702f0
|
||||
zh: f18615e7b6ea3631
|
||||
/agent-note-timed-user-questions-as-two-settlements/consequences:
|
||||
en: 12027f56af79db3c
|
||||
zh: 0a74c0aeaad2aad9
|
||||
/agent-note-timed-user-questions-as-two-settlements/acceptance-criteria:
|
||||
en: 5a993484c73befe9
|
||||
zh: 38cd3209c68178c3
|
||||
/agent-note-timed-user-questions-as-two-settlements/risks:
|
||||
en: aae260b0cd95554f
|
||||
zh: 3d7fbd6cbe9e2d37
|
||||
+10
-12
@@ -1,6 +1,6 @@
|
||||
# Agent Note: Timed user questions as two settlements
|
||||
|
||||
Status: implemented
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-09-19-timed-user-question-two-settlements.zh.md)
|
||||
|
||||
@@ -10,9 +10,9 @@ A question can block an agent even when useful work does not depend on the answe
|
||||
|
||||
The foreground tool result and the eventual answer have different lifetimes. A `user-questions/request` waterfall settles once; keeping it alive after returning pending creates a second answer path that outlives its caller. Countdown, focus, and editing also have different owners from durable question state.
|
||||
|
||||
## Decision
|
||||
## Proposal
|
||||
|
||||
Foreground answers use the existing Remote Event waterfall. A timeout ends that request and returns pending; a later answer uses a business RPC that steers a durable user message. The claim stream controls the foreground wait only. It neither transports answers nor replaces the Remote Event.
|
||||
Keep foreground answers on the existing Remote Event waterfall. A timeout ends that request and returns pending; a later answer uses a business RPC that steers a durable user message. The claim stream controls the foreground wait only. It neither transports answers nor replaces the Remote Event.
|
||||
|
||||
### Modes and scope
|
||||
|
||||
@@ -96,17 +96,15 @@ This decision adds no process-role field, independent-layout flag, user-message
|
||||
|
||||
**A dedicated late-reply Session event beside the inbox message.** The inbox splice and admitted user message already record the reply. A third event duplicates the same fact and adds another persistence type.
|
||||
|
||||
## Verification
|
||||
## Acceptance criteria
|
||||
|
||||
- [Service tests](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts) and [wait tests](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts) cover claimed and unclaimed waits, last-claim disconnect, cancellation, timeout error mapping, continued-only answers, and exact answer-id validation.
|
||||
- [Projection tests](../../../../packages/interaction/user-questions/tests/projection.spec.ts) distinguish legacy and timed schemas and cover pending, answered, other failures, resume repair, and duplicate inbox/message settlement.
|
||||
- [Client listener tests](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts) and [composer tests](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx) cover claim retention through Host acceptance, focus before handshake, indefinite waits, draft retention, RPC resubmission, and panel actions.
|
||||
- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover one rendered reply with both projections retained, ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
|
||||
- Legacy round-trip, cancellation, plan-review intent, and Session-remount regressions remain covered by their owning tests. Live multi-Client delivery races and real Host restart remain integration coverage gaps beyond isolated lifecycle and projection tests.
|
||||
- Service tests cover claimed and unclaimed waits, last-claim disconnect, cancellation, timeout error mapping, continued-only answers, and exact answer-id validation.
|
||||
- Projection tests distinguish legacy and timed schemas and cover pending, answered, other failures, resume repair, and duplicate inbox/message settlement.
|
||||
- Client tests cover claim retention through Host acceptance, focus before handshake, indefinite waits, draft retention, RPC resubmission, and panel hiding/reopening.
|
||||
- Grouping and browser replay cover one rendered reply with both projections retained, ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
|
||||
- Legacy question round trips, cancellation, plan-review intent, and Session remounts retain their existing behavior. Live multi-Client delivery races and real Host restart remain integration coverage gaps beyond isolated lifecycle and projection tests.
|
||||
|
||||
## Consequences
|
||||
|
||||
The model can continue independent work while the question remains answerable. Business-owned timing and reply rendering reuse the existing transport and conversation APIs, at the cost of local rather than globally coordinated waiting and ordinary folded visibility.
|
||||
## Risks
|
||||
|
||||
- Take time affects only one Client. Another Client can answer or expire the shared waterfall; local focus does not establish a global hold.
|
||||
- A terminal claim-stream failure can reject the foreground question rather than produce pending. No unattended-wait fallback is provided for that failure.
|
||||
+9
-11
@@ -1,6 +1,6 @@
|
||||
# Agent Note: 计时用户提问的两次结算
|
||||
|
||||
Status: implemented
|
||||
Status: proposed
|
||||
|
||||
[English](2026-09-19-timed-user-question-two-settlements.md) | 中文
|
||||
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
前台工具结果与最终回答的生命周期不同。`user-questions/request` waterfall(瀑布式事件)只结算一次;返回 pending 后仍保留它,会产生一条活过调用方的第二回答路径。倒计时、聚焦和编辑的归属也不同于持久问题状态。
|
||||
|
||||
## Decision
|
||||
## Proposal
|
||||
|
||||
前台回答继续使用现有 Remote Event waterfall。超时结束该请求并返回 pending;迟到回答通过业务 RPC steer 一条持久用户消息。接手 stream 只控制前台等待,既不传递回答,也不替代 Remote Event。
|
||||
|
||||
@@ -96,17 +96,15 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
|
||||
**在 inbox 消息之外增加专用迟到回复 Session 事件。** inbox 插入与接纳的用户消息已经记录回复。第三个事件重复同一事实,并增加另一种持久化类型。
|
||||
|
||||
## 验证
|
||||
## Acceptance criteria
|
||||
|
||||
- [服务测试](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts)与[等待测试](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts)覆盖有人与无人接手、最后接手方断开、取消、超时错误映射、仅接受已继续问题的回答,以及答案 id 的精确校验。
|
||||
- [投影测试](../../../../packages/interaction/user-questions/tests/projection.spec.ts)区分 legacy 与 timed schema,并覆盖 pending、已回答、其他失败、恢复修复及 inbox/消息重复结算。
|
||||
- [Client 监听器测试](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts)与[composer 测试](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx)覆盖接手保留到 Host 接受结果、握手前聚焦、无限期等待、草稿保留、RPC 重提及面板操作。
|
||||
- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖保留两个投影但只渲染一次回复、普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
|
||||
- legacy 问答往返、取消、计划评审意图及 Session 重新挂载的回归仍由各自测试覆盖。真实多 Client 投递竞争与真实 Host 重启仍是独立生命周期和投影测试之外的集成覆盖缺口。
|
||||
- 服务测试覆盖有人与无人接手、最后接手方断开、取消、超时错误映射、仅接受已继续问题的回答,以及答案 id 的精确校验。
|
||||
- 投影测试区分 legacy 与 timed schema,并覆盖 pending、已回答、其他失败、恢复修复及 inbox/消息重复结算。
|
||||
- Client 测试覆盖接手保留到 Host 接受结果、握手前聚焦、无限期等待、草稿保留、RPC 重提及面板隐藏/重开。
|
||||
- 分组与浏览器回放覆盖保留两个投影但只渲染一次回复、普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
|
||||
- legacy 问答往返、取消、计划评审意图及 Session 重新挂载保留既有行为。真实多 Client 投递竞争与真实 Host 重启仍是独立生命周期和投影测试之外的集成覆盖缺口。
|
||||
|
||||
## Consequences
|
||||
|
||||
模型可以继续独立工作,问题同时保持可回答。业务层拥有的计时与回复渲染复用现有传输和对话 API,代价是等待由本地而非全局协调,可见性遵循普通折叠。
|
||||
## Risks
|
||||
|
||||
- 「慢慢回答」只影响一个 Client。其他 Client 可以回答或让共享 waterfall 到期,本地聚焦不构成全局暂停。
|
||||
- 接手 stream 的终止性失败可能让前台问题失败,而不是产生 pending。不为该失败提供无人接手等待的 fallback。
|
||||
@@ -15,7 +15,6 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { TIMED_WAIT_PARAMETER } from '@deepseek-ai/dsh-user-questions'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
@@ -34,12 +33,8 @@ const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
|
||||
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
|
||||
const CANCELLED_EXPECTED = join(SNAPSHOT_DIR, 'cancelled.expected.md')
|
||||
const ANSWERED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'answered-expanded.expected.md')
|
||||
// The timed tool's second settlement: the wait expired, and the answer arrived
|
||||
// afterwards. Its golden owns the state the reported symptom lands in.
|
||||
const LATE_ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'late-answered.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const CANCELLED_SEED_ID = 'ask-question-cancelled-row-web-e2e'
|
||||
const LATE_ANSWERED_SEED_ID = 'ask-question-late-answered-row-web-e2e'
|
||||
|
||||
// The composer's own growth cap, in text lines (QuestionComposer.module.css
|
||||
// .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants
|
||||
@@ -75,100 +70,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** The batch the derived late reply submits, distinct from the in-time answer. */
|
||||
const LATE_ANSWERS = [{ id: 'color', selected: ['Green'], custom: 'Answered after the timeout' }]
|
||||
|
||||
/**
|
||||
* The `ask_user_question` schema as the timed tool declares it, in the shape a
|
||||
* request header records. A late reply can only follow a timed call, and the
|
||||
* `userQuestions` projection tells a timed call from a legacy one by exactly
|
||||
* this schema in the header in effect; the recorded fixture's header carries
|
||||
* the placeholder token of a legacy recording, so the derived fixture must
|
||||
* carry the timed schema for its call to be tracked at all.
|
||||
*/
|
||||
const TIMED_ASK_USER_QUESTION_SCHEMA = {
|
||||
name: 'ask_user_question',
|
||||
description: 'Ask brief, direct, self-contained questions about missing information, preferences, or decisions.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
questions: { type: 'array', items: { type: 'object' } },
|
||||
[TIMED_WAIT_PARAMETER]: { type: 'integer' },
|
||||
},
|
||||
required: ['questions'],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* What the timed tool records when its foreground wait expires. Both readers of
|
||||
* a recorded result take `pending` and `callId` out of this one JSON object —
|
||||
* the `userQuestions` projection to keep the call answerable, the question row
|
||||
* to tell a timeout from an answer batch; the `message` is the model's own
|
||||
* continuation instruction and no reader parses it.
|
||||
* @param callId - the call whose questions stay answerable.
|
||||
* @returns the recorded result text.
|
||||
*/
|
||||
function pendingResultText(callId: string): string {
|
||||
return JSON.stringify({
|
||||
pending: true,
|
||||
callId,
|
||||
message: 'No answer batch arrived before the timeout. This is pending, not a skipped answer.',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The message a late answer lands as: the timed question's `callId` names it,
|
||||
* and it carries both the questions it answers and the batch itself.
|
||||
* @param callId - the call the reply settles.
|
||||
* @param questions - the call's questions, echoed from its recorded arguments.
|
||||
* @returns the reply, as the inbox splice and the durable message both record it.
|
||||
*/
|
||||
function lateReplyMessage(callId: string, questions: unknown): Record<string, unknown> {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
kind: 'answer_to_pending_question', tool: 'ask_user_question', callId, questions, answers: LATE_ANSWERS,
|
||||
}),
|
||||
}],
|
||||
source: { kind: 'user-question-reply', callId, outcome: 'answered' },
|
||||
role: 'user',
|
||||
id: '{{message:7}}',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn a late reply opens: the reply enters the agent inbox, the next turn
|
||||
* claims it, and it lands as this Session's durable user message.
|
||||
* @param callId - the call the reply settles.
|
||||
* @param questions - the call's questions, echoed from its recorded arguments.
|
||||
* @returns the appended fixture lines, ending in the closing `turn/end`.
|
||||
*/
|
||||
function lateReplyTail(callId: string, questions: unknown): string[] {
|
||||
const message = lateReplyMessage(callId, questions)
|
||||
return [
|
||||
{ type: 'agent/inbox/spliced', data: { target: 'next-turn', start: 0, inserted: [message] } },
|
||||
{ type: 'turn/start', data: { turn: 2 } },
|
||||
{ type: 'agent/inbox/spliced', data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [] } },
|
||||
{ type: 'step/start', data: { turn: 2, step: 1 } },
|
||||
{ type: 'user/message', data: message, surfaceOp: 'append' },
|
||||
{ type: 'step/end', data: { turn: 2, step: 1 } },
|
||||
{ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
].map(event => JSON.stringify(event))
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive one settlement other than the recorded answer batch. The model step
|
||||
* that follows the Tool settlement reads the answer, so every derivation drops
|
||||
* it and keeps the recorded turn closed.
|
||||
* @param fixture - the recorded round trip.
|
||||
* @param outcome - `cancelled` settles the call as the user's own dismissal;
|
||||
* `late-answered` settles it as the timed tool's expired wait and appends the
|
||||
* reply that answers it a turn later.
|
||||
* @returns the derived fixture text.
|
||||
*/
|
||||
function resettledFixture(fixture: string, outcome: 'cancelled' | 'late-answered'): string {
|
||||
let asked: { callId: string; questions: unknown } | undefined
|
||||
/** Replace the successful Tool settlement and omit the answer-dependent model step. */
|
||||
function cancelledFixture(fixture: string): string {
|
||||
let replaced = false
|
||||
const lines: string[] = []
|
||||
for (const line of fixture.trimEnd().split('\n')) {
|
||||
@@ -187,26 +90,6 @@ function resettledFixture(fixture: string, outcome: 'cancelled' | 'late-answered
|
||||
|| event.type === 'turn/end') lines.push(line)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'request/header' && outcome === 'late-answered') {
|
||||
// The recording was made under the blocking legacy tool; the derived
|
||||
// timed settlement needs the header that tool's timed sibling records.
|
||||
const data = event.data
|
||||
if (!isRecord(data) || !isRecord(data.header)) throw new Error('question fixture request/header is invalid')
|
||||
data.header.tools = [TIMED_ASK_USER_QUESTION_SCHEMA]
|
||||
lines.push(JSON.stringify(event))
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/call') {
|
||||
const data = event.data
|
||||
if (!isRecord(data) || typeof data.callId !== 'string' || typeof data.arguments !== 'string') {
|
||||
throw new Error('question fixture tool/call is invalid')
|
||||
}
|
||||
const args: unknown = JSON.parse(data.arguments)
|
||||
if (!isRecord(args)) throw new Error('question fixture tool/call arguments are invalid')
|
||||
asked = { callId: data.callId, questions: args.questions }
|
||||
lines.push(line)
|
||||
continue
|
||||
}
|
||||
if (event.type !== 'tool/result') {
|
||||
lines.push(line)
|
||||
continue
|
||||
@@ -217,58 +100,20 @@ function resettledFixture(fixture: string, outcome: 'cancelled' | 'late-answered
|
||||
if (!isRecord(message) || !Array.isArray(message.content) || !isRecord(message.content[0])) {
|
||||
throw new Error('question fixture tool/result message is invalid')
|
||||
}
|
||||
if (asked === undefined) throw new Error('question fixture settles a call it never made')
|
||||
if (outcome === 'cancelled') {
|
||||
message.content[0].content = [{
|
||||
type: 'text',
|
||||
text: 'Error: the user cancelled ask_user_question',
|
||||
}]
|
||||
message.content[0].isError = true
|
||||
data.error = {
|
||||
name: 'UserQuestionError',
|
||||
code: 'ASK_CANCELLED',
|
||||
}
|
||||
} else {
|
||||
message.content[0].content = [{ type: 'text', text: pendingResultText(asked.callId) }]
|
||||
message.content[0].content = [{
|
||||
type: 'text',
|
||||
text: 'Error: the user cancelled ask_user_question',
|
||||
}]
|
||||
message.content[0].isError = true
|
||||
data.error = {
|
||||
name: 'UserQuestionError',
|
||||
code: 'ASK_CANCELLED',
|
||||
}
|
||||
replaced = true
|
||||
lines.push(JSON.stringify(event))
|
||||
}
|
||||
if (!replaced || asked === undefined) throw new Error('question fixture has no settled ask_user_question call')
|
||||
const tail = outcome === 'cancelled' ? [] : lateReplyTail(asked.callId, asked.questions)
|
||||
return `${[...lines, ...tail].join('\n')}\n`
|
||||
}
|
||||
|
||||
/** One derived transcript, seeded into its own scaffold and opened in the browser. */
|
||||
interface SeededTranscript {
|
||||
readonly scaffold: WebScaffold
|
||||
readonly browser: Browser
|
||||
readonly page: Page
|
||||
readonly tripwire: ReturnType<typeof watchConsole>
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed one derived fixture and open its Session in a fresh browser.
|
||||
* @param fixtureText - the derived fixture to persist.
|
||||
* @param seedId - the seeded Session id, which its golden tokenizes.
|
||||
* @returns the scaffold, browser, page, and console tripwire teardown closes.
|
||||
*/
|
||||
async function openSeededTranscript(fixtureText: string, seedId: string): Promise<SeededTranscript> {
|
||||
const scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, fixtureText, seedId)
|
||||
const browser = await chromium.launch()
|
||||
const page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
return { scaffold, browser, page, tripwire }
|
||||
if (!replaced) throw new Error('question fixture has no tool/result event')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
describe('web e2e: resident question composer round trip', () => {
|
||||
@@ -407,15 +252,6 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await custom.inputValue()).toBe('Include accessibility notes')
|
||||
if (MODE !== 'record') {
|
||||
// Replay only, like the measurements above: record mode must reach the
|
||||
// recording write. This fixture composes the shipped legacy tool, whose
|
||||
// request carries no call id and whose row offers no pill: the projection
|
||||
// never lists a legacy call, so the transcript reads exactly as it did
|
||||
// before timed questions existed. Reopening a hidden panel belongs to the
|
||||
// timed tool; the late-answered scenario below and the ui-user-questions
|
||||
// unit specs cover it.
|
||||
expect(await page.getByRole('button', { name: 'Answer', exact: true }).count()).toBe(0)
|
||||
|
||||
// A strict Session-slot switch remounts the composer. Open a fresh blank
|
||||
// Session, then return to the still-waiting request and require its
|
||||
// Session-scoped store to restore both option and free-text drafts.
|
||||
@@ -466,15 +302,12 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
// Keep the ask_user_question card's readable answer in the expanded golden
|
||||
// even though Compact mode hides the process by default.
|
||||
await expandTurnProcesses(page)
|
||||
// A legacy call's answered row carries no pill and no read-only panel; its
|
||||
// accessible name is the bare summary, as before timed questions existed.
|
||||
const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true })
|
||||
await answeredRow.click()
|
||||
await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByText('Blue', { exact: true }).count()).toBeGreaterThanOrEqual(1)
|
||||
expect(await page.getByText('Include accessibility notes', { exact: true }).count()).toBe(1)
|
||||
expect(await page.getByText(/"answers"/).count()).toBe(0)
|
||||
expect(await page.getByRole('button', { name: 'View answers', exact: true }).count()).toBe(0)
|
||||
const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(ANSWERED_EXPANDED_EXPECTED, expanded, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
@@ -525,22 +358,38 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => {
|
||||
let seeded: SeededTranscript
|
||||
let cancelledScaffold: WebScaffold
|
||||
let cancelledBrowser: Browser
|
||||
let cancelledPage: Page
|
||||
let cancelledTripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
seeded = await openSeededTranscript(
|
||||
resettledFixture(await readFile(FIXTURE, 'utf8'), 'cancelled'),
|
||||
cancelledScaffold = await launchWebScaffold({})
|
||||
await seedSession(
|
||||
cancelledScaffold,
|
||||
cancelledFixture(await readFile(FIXTURE, 'utf8')),
|
||||
CANCELLED_SEED_ID,
|
||||
)
|
||||
cancelledBrowser = await chromium.launch()
|
||||
cancelledPage = await newEnglishPage(cancelledBrowser)
|
||||
cancelledTripwire = watchConsole(cancelledPage)
|
||||
await cancelledPage.goto(cancelledScaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await cancelledPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
const groupRow = cancelledPage.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = cancelledPage.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await seeded?.browser.close()
|
||||
await seeded?.scaffold.close()
|
||||
await cancelledBrowser?.close()
|
||||
await cancelledScaffold?.close()
|
||||
})
|
||||
|
||||
it('expands to the cancellation verdict and original questions', async () => {
|
||||
const { page: cancelledPage, scaffold, tripwire } = seeded
|
||||
onTestFailed(() => saveFailureShot(cancelledPage, 'web-e2e-question-cancelled-row'))
|
||||
await expandTurnProcesses(cancelledPage)
|
||||
const row = cancelledPage.getByRole('button', { name: 'Ask question cancelled', exact: true })
|
||||
@@ -558,81 +407,11 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', ()
|
||||
const snapshot = (await captureStableAria(
|
||||
cancelledPage,
|
||||
'[class*="centerCol"]',
|
||||
scaffold.workspaceCwd,
|
||||
cancelledScaffold.workspaceCwd,
|
||||
)).split(CANCELLED_SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(CANCELLED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
// The timed tool's own second settlement. The recorded result is the expired
|
||||
// wait, so the answers live in the reply that landed a turn later and reach the
|
||||
// row only through the `userQuestions` projection — the whole chain the reported
|
||||
// symptom broke on, where an answered question kept no way back to its answers.
|
||||
describe.skipIf(MODE === 'record')('web e2e: late-answered question transcript', () => {
|
||||
let seeded: SeededTranscript
|
||||
|
||||
beforeAll(async () => {
|
||||
seeded = await openSeededTranscript(
|
||||
resettledFixture(await readFile(FIXTURE, 'utf8'), 'late-answered'),
|
||||
LATE_ANSWERED_SEED_ID,
|
||||
)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await seeded?.browser.close()
|
||||
await seeded?.scaffold.close()
|
||||
})
|
||||
|
||||
it('reads a late answer as an answered question and reopens its answers', async () => {
|
||||
const { page: latePage, scaffold, tripwire } = seeded
|
||||
onTestFailed(() => saveFailureShot(latePage, 'web-e2e-question-late-answered-row'))
|
||||
const reply = latePage.locator('[data-question-reply]')
|
||||
await reply.waitFor({ state: 'attached', timeout: 15_000 })
|
||||
expect(await reply.count()).toBe(1)
|
||||
expect(await latePage.locator('[data-chat-flow-kind="turn-trigger"]').count()).toBe(0)
|
||||
expect(await reply.isVisible()).toBe(false)
|
||||
// Verbatim the accessible name of the in-time answered row above: whether
|
||||
// the answer beat the timeout is the agent's pacing, not something a reader
|
||||
// of the row has to reason about.
|
||||
await expandTurnProcesses(latePage)
|
||||
await reply.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
const row = latePage.getByRole('button', { name: 'Ask question 1/1 answered View answers', exact: true })
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
// The pill owns its own clicks — press near the leading glyph so this stays
|
||||
// the row's disclosure.
|
||||
await row.click({ position: { x: 5, y: 5 } })
|
||||
await latePage.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await latePage.getByText('Green', { exact: true }).count()).toBeGreaterThanOrEqual(1)
|
||||
expect(await latePage.getByText('Answered after the timeout', { exact: true }).count()).toBeGreaterThanOrEqual(1)
|
||||
// The recorded timeout is the row's state, never its transcript copy.
|
||||
expect(await latePage.getByText(/"pending"/).count()).toBe(0)
|
||||
|
||||
// The same read-only panel an in-time answer reopens, over the answers the
|
||||
// late reply carried.
|
||||
await latePage.getByRole('button', { name: 'View answers', exact: true }).click()
|
||||
const review = latePage.locator('[data-question-key]')
|
||||
await review.waitFor({ timeout: 10_000 })
|
||||
await review.getByText('Answered', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
const recorded = review.getByRole('checkbox', { name: 'Green' })
|
||||
expect(await recorded.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await recorded.isDisabled()).toBe(true)
|
||||
expect(await review.getByRole('checkbox', { name: 'Blue' }).getAttribute('aria-checked')).toBe('false')
|
||||
const recordedCustom = review.getByRole('textbox')
|
||||
expect(await recordedCustom.inputValue()).toBe('Answered after the timeout')
|
||||
expect(await recordedCustom.isDisabled()).toBe(true)
|
||||
expect(await review.getByRole('button', { name: 'Submit' }).count()).toBe(0)
|
||||
expect(await review.getByRole('button', { name: 'Skip' }).count()).toBe(0)
|
||||
|
||||
const snapshot = (await captureStableAria(
|
||||
latePage,
|
||||
'[class*="centerCol"]',
|
||||
scaffold.workspaceCwd,
|
||||
)).split(LATE_ANSWERED_SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(LATE_ANSWERED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(cancelledTripwire.pageErrors).toEqual([])
|
||||
expect(cancelledTripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
@@ -644,7 +423,6 @@ describe.skipIf(MODE === 'record')('web e2e: late-answered question transcript',
|
||||
'answered.expected.md',
|
||||
'cancelled.expected.md',
|
||||
'answered-expanded.expected.md',
|
||||
'late-answered.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
+1
-19
@@ -3424,25 +3424,6 @@ export type TokenMeterConfig = Record<string, never>
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-token-meter -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-tool-ask-user -->
|
||||
<a id="deepseek-aidsh-tool-ask-user"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-ask-user`
|
||||
|
||||
- `inject`: `tools` · `userQuestions`
|
||||
- `source`: [`packages/interaction/tool-ask-user/src/index.ts:19`](../packages/interaction/tool-ask-user/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Default wait in seconds; -1 retains the blocking tool behavior. */
|
||||
export interface Config {
|
||||
/** Tool definition selected by this Cordis row. Defaults to the blocking legacy tool. */
|
||||
mode?: 'legacy' | 'timed'
|
||||
/** Foreground wait before automatic continuation. Defaults to 120 seconds. */
|
||||
timeout?: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-tool-ask-user -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-tool-bash -->
|
||||
<a id="deepseek-aidsh-tool-bash"></a>
|
||||
|
||||
@@ -4385,6 +4366,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-subprocess-local` | — | [`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-subprocess-ssh` | `ssh` | [`packages/ssh/subprocess-ssh/src/index.ts`](../packages/ssh/subprocess-ssh/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-terminal` | — | [`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | `tools` · `userQuestions` | [`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-call-timeout-policy` | `tools` | [`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) |
|
||||
|
||||
@@ -3426,25 +3426,6 @@ export type TokenMeterConfig = Record<string, never>
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-token-meter -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-tool-ask-user -->
|
||||
<a id="deepseek-aidsh-tool-ask-user"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-ask-user`
|
||||
|
||||
- `inject`: `tools` · `userQuestions`
|
||||
- `source`: [`packages/interaction/tool-ask-user/src/index.ts:19`](../packages/interaction/tool-ask-user/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Default wait in seconds; -1 retains the blocking tool behavior. */
|
||||
export interface Config {
|
||||
/** Tool definition selected by this Cordis row. Defaults to the blocking legacy tool. */
|
||||
mode?: 'legacy' | 'timed'
|
||||
/** Foreground wait before automatic continuation. Defaults to 120 seconds. */
|
||||
timeout?: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-tool-ask-user -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-tool-bash -->
|
||||
<a id="deepseek-aidsh-tool-bash"></a>
|
||||
|
||||
@@ -4387,6 +4368,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-subprocess-local` | — | [`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-subprocess-ssh` | `ssh` | [`packages/ssh/subprocess-ssh/src/index.ts`](../packages/ssh/subprocess-ssh/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-terminal` | — | [`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | `tools` · `userQuestions` | [`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-call-timeout-policy` | `tools` | [`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) |
|
||||
|
||||
@@ -79,7 +79,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:153`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:160`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:153`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:160`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -662,9 +662,6 @@ flowchart TD
|
||||
pkg_user_questions --> pkg_agent
|
||||
pkg_user_questions --> pkg_llm
|
||||
pkg_user_questions --> pkg_scope
|
||||
pkg_user_questions --> pkg_session
|
||||
pkg_user_questions --> pkg_session_projection
|
||||
pkg_user_questions --> pkg_typert_protocol
|
||||
pkg_jobs --> pkg_agent
|
||||
pkg_jobs --> pkg_brand
|
||||
pkg_jobs --> pkg_invariants
|
||||
@@ -1560,7 +1557,7 @@ flowchart TD
|
||||
| [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) |
|
||||
|
||||
@@ -664,9 +664,6 @@ flowchart TD
|
||||
pkg_user_questions --> pkg_agent
|
||||
pkg_user_questions --> pkg_llm
|
||||
pkg_user_questions --> pkg_scope
|
||||
pkg_user_questions --> pkg_session
|
||||
pkg_user_questions --> pkg_session_projection
|
||||
pkg_user_questions --> pkg_typert_protocol
|
||||
pkg_jobs --> pkg_agent
|
||||
pkg_jobs --> pkg_brand
|
||||
pkg_jobs --> pkg_invariants
|
||||
@@ -1562,7 +1559,7 @@ flowchart TD
|
||||
| [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) |
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: e7c80d1de4d328b4
|
||||
zh: d9f368c27fd9f998
|
||||
/session-persistence-event-catalog/persistence-type-fingerprints:
|
||||
en: 31841c2d7c75bcc4
|
||||
zh: 67cb5231a59d18bc
|
||||
en: 0254fc43535dfaad
|
||||
zh: 2f592cfae60e10ac
|
||||
/session-persistence-event-catalog/event-envelope:
|
||||
en: 7ebb1a2e0dd1f916
|
||||
zh: 8946f415dda5c1b8
|
||||
@@ -318,11 +318,8 @@
|
||||
en: 6c3effe2abc1aa53
|
||||
zh: 6c3effe2abc1aa53
|
||||
/session-persistence-event-catalog/resolved-persistence-types/always:
|
||||
en: 4cdaf9853769f3da
|
||||
zh: 4cdaf9853769f3da
|
||||
/session-persistence-event-catalog/resolved-persistence-types/answered:
|
||||
en: 9b5e8719c6d3d7ff
|
||||
zh: 9b5e8719c6d3d7ff
|
||||
en: e318559064b455fb
|
||||
zh: e318559064b455fb
|
||||
/session-persistence-event-catalog/resolved-persistence-types/append:
|
||||
en: 02f63fd38e94120b
|
||||
zh: 02f63fd38e94120b
|
||||
@@ -441,11 +438,8 @@
|
||||
en: 42389e30f99e210f
|
||||
zh: 42389e30f99e210f
|
||||
/session-persistence-event-catalog/resolved-persistence-types/developer-message:
|
||||
en: e2f7f261557b02eb
|
||||
zh: e2f7f261557b02eb
|
||||
/session-persistence-event-catalog/resolved-persistence-types/dismissed:
|
||||
en: b14bbe98cead8849
|
||||
zh: b14bbe98cead8849
|
||||
en: e9a6abc2549ddf91
|
||||
zh: e9a6abc2549ddf91
|
||||
/session-persistence-event-catalog/resolved-persistence-types/dispatch:
|
||||
en: aae8924fd5eb9013
|
||||
zh: aae8924fd5eb9013
|
||||
@@ -870,11 +864,8 @@
|
||||
en: f01476bd214bb78f
|
||||
zh: f01476bd214bb78f
|
||||
/session-persistence-event-catalog/resolved-persistence-types/user-approval:
|
||||
en: 1c8f591ed011f359
|
||||
zh: 1c8f591ed011f359
|
||||
/session-persistence-event-catalog/resolved-persistence-types/user-question-reply:
|
||||
en: ce169abc5bc0ad1f
|
||||
zh: ce169abc5bc0ad1f
|
||||
en: 7c6de40d31cf20a5
|
||||
zh: 7c6de40d31cf20a5
|
||||
/session-persistence-event-catalog/resolved-persistence-types/user-message:
|
||||
en: ecc3b25a07772288
|
||||
zh: ecc3b25a07772288
|
||||
@@ -960,11 +951,11 @@
|
||||
en: 586743dfeab78c55
|
||||
zh: 26ccef4a02dd7a58
|
||||
/session-persistence-event-catalog/resolved-persistence-types/deepseeksearchllmrequest:
|
||||
en: 5b173514ba22f99a
|
||||
zh: 45be438c1c8a580c
|
||||
en: b3df6dce576b01a2
|
||||
zh: f9c67e338cc6e449
|
||||
/session-persistence-event-catalog/resolved-persistence-types/developermessage:
|
||||
en: 43fa1595d4287c14
|
||||
zh: 48a837250a80d0f1
|
||||
en: 3df6e6aaeb92c0fa
|
||||
zh: 29988ebcdd9e62e1
|
||||
/session-persistence-event-catalog/resolved-persistence-types/epochheader:
|
||||
en: d31020fa1fda6fa4
|
||||
zh: 77984c4bf09443fb
|
||||
@@ -1062,11 +1053,11 @@
|
||||
en: 6dbe099c62f7c226
|
||||
zh: 6fd788f63c038c91
|
||||
/session-persistence-event-catalog/resolved-persistence-types/llmretrystartedeventdata:
|
||||
en: b0a0d363567cc73d
|
||||
zh: 3fbd5a13a542169c
|
||||
en: 7854b9694cc84c3a
|
||||
zh: 319ec2e2e6ffdaec
|
||||
/session-persistence-event-catalog/resolved-persistence-types/message:
|
||||
en: c8a1668f362c7917
|
||||
zh: 417905691156e5f3
|
||||
en: 45a296c31664de87
|
||||
zh: 8f235285b2f985f9
|
||||
/session-persistence-event-catalog/resolved-persistence-types/messagefeedbackdelete:
|
||||
en: c582953270d4ca44
|
||||
zh: 966ccfe2e7660a2a
|
||||
@@ -1077,14 +1068,14 @@
|
||||
en: f58575a1fc96df42
|
||||
zh: 9f0c6ed9033e5c9d
|
||||
/session-persistence-event-catalog/resolved-persistence-types/messagefeedbackrating:
|
||||
en: b89e5af2d3b4da77
|
||||
zh: 2e7fb3b98cfb1dac
|
||||
en: 490a91f1da1fc870
|
||||
zh: 2f2b9e972e22d51f
|
||||
/session-persistence-event-catalog/resolved-persistence-types/messagesource:
|
||||
en: 0b83d49688e10273
|
||||
zh: f0f2b92a36753a2f
|
||||
en: 7d0eda01d2c3076f
|
||||
zh: ce3dbfc1bd7fd176
|
||||
/session-persistence-event-catalog/resolved-persistence-types/message~2:
|
||||
en: 70558eab25fefa3f
|
||||
zh: b14a21a6b8bbe0ba
|
||||
en: 8e53b2a339808ffc
|
||||
zh: d453e93e45f9e752
|
||||
/session-persistence-event-catalog/resolved-persistence-types/modelmessagesource:
|
||||
en: 3b677f021d7dd4d3
|
||||
zh: 2a875742b22879a2
|
||||
@@ -1143,11 +1134,11 @@
|
||||
en: 9a54c4478ad3b620
|
||||
zh: 02c4c1fd7e252770
|
||||
/session-persistence-event-catalog/resolved-persistence-types/sessiontitleeventdata:
|
||||
en: 8d27d0fcf7b4c492
|
||||
zh: 620f967622e4e63a
|
||||
en: 415ce281c096b4c3
|
||||
zh: 39c5bf9dfaa95579
|
||||
/session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata:
|
||||
en: 05c6b11740a3b06c
|
||||
zh: bfe01ac682d9f938
|
||||
en: 0e539718e4bc8ed8
|
||||
zh: 060007f55759f065
|
||||
/session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity:
|
||||
en: 9531468264499498
|
||||
zh: 9da23c0ad2776563
|
||||
@@ -1254,14 +1245,14 @@
|
||||
en: 544292eddeff5faa
|
||||
zh: 4e36e575a5030fb2
|
||||
/session-persistence-event-catalog/resolved-persistence-types/turnendreason:
|
||||
en: 4577fec1a00cf57d
|
||||
zh: f61550f7a11c4099
|
||||
en: 083f0fef130bb976
|
||||
zh: dafd261a95708bec
|
||||
/session-persistence-event-catalog/resolved-persistence-types/usermessage:
|
||||
en: 2623f0d05f65f7d9
|
||||
zh: ddbd4b32a1cedeba
|
||||
en: 88b294a9b0bdf0e4
|
||||
zh: 0597f465b9d45b27
|
||||
/session-persistence-event-catalog/resolved-persistence-types/usermessage~2:
|
||||
en: 770f9f9f427167aa
|
||||
zh: eab99a7dd69c5fd4
|
||||
en: 31aaab9c9f53ed9c
|
||||
zh: c5cd866b3f2b27e0
|
||||
/session-persistence-event-catalog/resolved-persistence-types/workflowagentoutcome:
|
||||
en: d0a3f9aab18c3de5
|
||||
zh: 76ac69a835ca4e29
|
||||
@@ -1305,15 +1296,12 @@
|
||||
en: d4d63e6e0832fd5b
|
||||
zh: 3af90f9fc14f48da
|
||||
/session-persistence-event-catalog/resolved-persistence-types/union-2-variants:
|
||||
en: b81872c733894f03
|
||||
zh: 04b6221ce19a233e
|
||||
en: f692844b29923b2e
|
||||
zh: 72f12bca08d63267
|
||||
/session-persistence-event-catalog/resolved-persistence-types/union-2-variants~2:
|
||||
en: 4db67a1c537b0d84
|
||||
zh: 810e334b2b214238
|
||||
/session-persistence-event-catalog/resolved-persistence-types/union-2-variants~3:
|
||||
en: 69d360cf5a1ae6b4
|
||||
zh: f23faf39a2a0e6f1
|
||||
/session-persistence-event-catalog/resolved-persistence-types/union-2-variants~4:
|
||||
/session-persistence-event-catalog/resolved-persistence-types/union-2-variants~3:
|
||||
en: 5a1af9849b0967ea
|
||||
zh: 1085d793bbc47360
|
||||
/session-persistence-event-catalog/resolved-persistence-types/union-3-variants:
|
||||
@@ -1434,11 +1422,11 @@
|
||||
en: 51af619ed8532a43
|
||||
zh: a5ad878c63d0e046
|
||||
/session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries:
|
||||
en: f0ff6e8b547e59a1
|
||||
zh: c17ac6f98ef72c50
|
||||
en: 62ca405eba4d2757
|
||||
zh: c6a8861533363ae9
|
||||
/session-persistence-event-catalog/resolved-persistence-types/headerseq-message-step-turn:
|
||||
en: 5eb089927e206419
|
||||
zh: 9c9bcfa3b59e3a93
|
||||
en: 76e7be9a80a4082c
|
||||
zh: c06aaa42a8488aea
|
||||
/session-persistence-event-catalog/resolved-persistence-types/height-width:
|
||||
en: a5df50710083ac25
|
||||
zh: aad3ccf6f6de70e4
|
||||
@@ -1455,11 +1443,11 @@
|
||||
en: 41f73c58a77dfa28
|
||||
zh: af77f4aee3cb4a40
|
||||
/session-persistence-event-catalog/resolved-persistence-types/inherited:
|
||||
en: fd7cfa6b80e3e1a7
|
||||
zh: 2dacc297f1aa9840
|
||||
en: f538b60f99becc0a
|
||||
zh: 0c8efa059a1e6569
|
||||
/session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start:
|
||||
en: ac06e73a90aaef38
|
||||
zh: 71a6013618e43e22
|
||||
en: 3985827b3ead1d92
|
||||
zh: c535166ccae6c8a3
|
||||
/session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream:
|
||||
en: f460572284c92755
|
||||
zh: bae7561ce887b040
|
||||
@@ -1788,11 +1776,8 @@
|
||||
en: eaffa616c5b9dc9a
|
||||
zh: 2eb138fcf5936e20
|
||||
/session-persistence-event-catalog/resolved-persistence-types/kind-user-approval-form-snapshot:
|
||||
en: cc6497e06528ca95
|
||||
zh: d03e6225274369d1
|
||||
/session-persistence-event-catalog/resolved-persistence-types/kind-user-question-reply:
|
||||
en: aed630ccf32bc001
|
||||
zh: a9142a26c19e933f
|
||||
en: 58c4ff5b5d70cc02
|
||||
zh: 080be72a31b3205e
|
||||
/session-persistence-event-catalog/resolved-persistence-types/kind-webhook-form-notice:
|
||||
en: 4cd5dc7523fefebb
|
||||
zh: f467561fbd0ae394
|
||||
@@ -1851,11 +1836,11 @@
|
||||
en: a79b2ab16b9ed215
|
||||
zh: 764cd1400ee8db63
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-agent-preset-selected:
|
||||
en: 10f1d820ae610d45
|
||||
zh: d5cfa158baee2bb0
|
||||
en: d59bb505610b862f
|
||||
zh: e2637fbbb8365bdf
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-agent-inbox-spliced:
|
||||
en: af155a8697274223
|
||||
zh: 6ca0d7888c1b1651
|
||||
en: 109d942de30d9227
|
||||
zh: 6c6fc06634ad7945
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-approval-asked:
|
||||
en: 9c77f18b0dad22f0
|
||||
zh: 8894cf2878fe90db
|
||||
@@ -1899,11 +1884,11 @@
|
||||
en: ba73fb10b7e2ffc9
|
||||
zh: 8d5c97e63e5f16da
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-deliverables-presented:
|
||||
en: a9d07eb72229dc21
|
||||
zh: 747169a607ff869e
|
||||
en: 49de6f547778d916
|
||||
zh: ce2a1af1ae934f68
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-developer-message:
|
||||
en: aa1d22db9502eaeb
|
||||
zh: a3caf25d34c1d72c
|
||||
en: 686d26ced9945d4b
|
||||
zh: 535b9e92eb7535fb
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-feedback-message-delete:
|
||||
en: 204561e2311db97e
|
||||
zh: 77264396bd5e25c4
|
||||
@@ -1968,11 +1953,11 @@
|
||||
en: acf7e21e4d21ebff
|
||||
zh: fb83a853634d204a
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-session-title:
|
||||
en: e429676476efea69
|
||||
zh: fca1dd77216a7eca
|
||||
en: ba2f901c9981835f
|
||||
zh: ce0358c7d16b0beb
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-session-title-llm-request:
|
||||
en: 7b1a43a5da8444f0
|
||||
zh: 15d92132c6fdbcb0
|
||||
en: 7515d391badb4d0a
|
||||
zh: 9eb19def7a3ed752
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-step-end:
|
||||
en: 7fc233c4a569dae2
|
||||
zh: bc2b3fed4a397c00
|
||||
@@ -2049,11 +2034,11 @@
|
||||
en: 1b045de96172be4b
|
||||
zh: 8a15c6f35b576b37
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-usage:
|
||||
en: 5d9c18f4533569a6
|
||||
zh: f69e1f3fd9dc0188
|
||||
en: a01e61480accb34b
|
||||
zh: 6387a3cc7666aa7e
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-user-message:
|
||||
en: 5a9360fd7cba2042
|
||||
zh: a159049cc36b7c37
|
||||
en: 58d3e616db84f840
|
||||
zh: 9aed64a03347caad
|
||||
/session-persistence-event-catalog/resolved-persistence-types/type-web-deepseek-search-llm-request:
|
||||
en: f109997e295af37d
|
||||
zh: b2dcfd599f73b61c
|
||||
|
||||
+45
-95
@@ -21,7 +21,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
|
||||
| `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) |
|
||||
| `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) |
|
||||
| `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) |
|
||||
| `event:agent/inbox/spliced` | event | `99a3e14f0b0ff0e38d345e7cb7d8d81ee8d11c51d611d9f91b1a82c8ea481fc4` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-99a3e14f0b0ff0e38d345e7cb7d8d81ee8d11c51d611d9f91b1a82c8ea481fc4) |
|
||||
| `event:agent/inbox/spliced` | event | `1506a9b8224986c83015ae99d2cb5ede705538c58c063d6a48ef6d761a31ba6c` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-1506a9b8224986c83015ae99d2cb5ede705538c58c063d6a48ef6d761a31ba6c) |
|
||||
| `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) |
|
||||
| `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) |
|
||||
| `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) |
|
||||
@@ -34,7 +34,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
|
||||
| `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) |
|
||||
| `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) |
|
||||
| `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) |
|
||||
| `event:developer/message` | event | `5d588f99e950c42bdd55a27fa41e98f5c86db5b0523622d85b99604c7ddb0107` | [`{ type: "developer/message" }`](#persistence-type-sha256-5d588f99e950c42bdd55a27fa41e98f5c86db5b0523622d85b99604c7ddb0107) |
|
||||
| `event:developer/message` | event | `eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80` | [`{ type: "developer/message" }`](#persistence-type-sha256-eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80) |
|
||||
| `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) |
|
||||
| `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) |
|
||||
| `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) |
|
||||
@@ -54,7 +54,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
|
||||
| `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) |
|
||||
| `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) |
|
||||
| `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) |
|
||||
| `event:session/title-llm-request` | event | `64170a859320a56b572372f9ab16e31c4ae9c62454a31418e900ebbf90053c68` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-64170a859320a56b572372f9ab16e31c4ae9c62454a31418e900ebbf90053c68) |
|
||||
| `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) |
|
||||
| `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) |
|
||||
| `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) |
|
||||
| `event:subagent/catalog` | event | `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604) |
|
||||
@@ -76,7 +76,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
|
||||
| `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) |
|
||||
| `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) |
|
||||
| `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) |
|
||||
| `event:user/message` | event | `1d1697092d04c08f9fcb99015ac082e58b01b7de9e41611fa6aebf2879c3d083` | [`{ type: "user/message" }`](#persistence-type-sha256-1d1697092d04c08f9fcb99015ac082e58b01b7de9e41611fa6aebf2879c3d083) |
|
||||
| `event:user/message` | event | `3f72db3d87a0c5c43e68be467b4cca728eaf5adc1d5d2b6975ff42bfbd961761` | [`{ type: "user/message" }`](#persistence-type-sha256-3f72db3d87a0c5c43e68be467b4cca728eaf5adc1d5d2b6975ff42bfbd961761) |
|
||||
| `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331) |
|
||||
| `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) |
|
||||
|
||||
@@ -1357,14 +1357,6 @@ SHA-256: `142c37d302aa22a0a57915b5255939d3ca91c1811ca13fddcf84cf76ea00758a`
|
||||
|
||||
`"always"`
|
||||
|
||||
<a id="persistence-type-sha256-85d6d96ff884297410ec0026bdacba835f10e56cbcc5a3b9d670739f4aa0494b"></a>
|
||||
|
||||
### `"answered"`
|
||||
|
||||
SHA-256: `85d6d96ff884297410ec0026bdacba835f10e56cbcc5a3b9d670739f4aa0494b`
|
||||
|
||||
`"answered"`
|
||||
|
||||
<a id="persistence-type-sha256-e438a9d4fb883b3e4a8d9fc8913aa1550ed50eb384bfbf943100a5605b537498"></a>
|
||||
|
||||
### `"append"`
|
||||
@@ -1685,14 +1677,6 @@ SHA-256: `1379f0b72275994d20f1ddca2d60a96ab16ec80397ca9f1af21bdc67ba5c269c`
|
||||
|
||||
`"developer/message"`
|
||||
|
||||
<a id="persistence-type-sha256-39a6d6b657a33d9e62e6fe1ce35b44df38b6a0fef72a87ee45ec318ebfe8f4b6"></a>
|
||||
|
||||
### `"dismissed"`
|
||||
|
||||
SHA-256: `39a6d6b657a33d9e62e6fe1ce35b44df38b6a0fef72a87ee45ec318ebfe8f4b6`
|
||||
|
||||
`"dismissed"`
|
||||
|
||||
<a id="persistence-type-sha256-26c879bdd130e8b8683a6ce588414fad2244783d6573ca399fa0de184a2b0bb1"></a>
|
||||
|
||||
### `"dispatch"`
|
||||
@@ -2835,14 +2819,6 @@ SHA-256: `f68c335306535a001127dae6685ece493a0ee7f4e2a851197b6054d8a9ff7394`
|
||||
|
||||
`"user-approval"`
|
||||
|
||||
<a id="persistence-type-sha256-9645cf93769a6dfb227bd400bd6cb9fa7bab9140f4a851e4ae92514ade6e3114"></a>
|
||||
|
||||
### `"user-question-reply"`
|
||||
|
||||
SHA-256: `9645cf93769a6dfb227bd400bd6cb9fa7bab9140f4a851e4ae92514ade6e3114`
|
||||
|
||||
`"user-question-reply"`
|
||||
|
||||
<a id="persistence-type-sha256-de25352d2dc4196309bd84b5bac6319f6d5c898ea0e82bb5ae66c5738d0d426f"></a>
|
||||
|
||||
### `"user/message"`
|
||||
@@ -3231,7 +3207,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web
|
||||
| `body` | required | [`{ max_tokens, messages, model, tools }`](#persistence-type-sha256-930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731) |
|
||||
| `endpoint` | required | `string` |
|
||||
|
||||
<a id="persistence-type-sha256-19ac4d9fb655ae9f2d9e8993d083d3eb9aede910d0454b892eb6e1290f55179c"></a>
|
||||
<a id="persistence-type-sha256-f61313bd96ed95b72eb16a4a5a77ad94a0da1756d428d3d660ff6e23c9e6342b"></a>
|
||||
|
||||
<a id="persistence-type-developermessage"></a>
|
||||
|
||||
@@ -3239,7 +3215,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web
|
||||
|
||||
### `DeveloperMessage`
|
||||
|
||||
SHA-256: `19ac4d9fb655ae9f2d9e8993d083d3eb9aede910d0454b892eb6e1290f55179c`
|
||||
SHA-256: `f61313bd96ed95b72eb16a4a5a77ad94a0da1756d428d3d660ff6e23c9e6342b`
|
||||
|
||||
Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts)
|
||||
|
||||
@@ -3248,11 +3224,11 @@ Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message
|
||||
| `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) |
|
||||
| `id` | required | `string` |
|
||||
| `role` | required | `"developer"` |
|
||||
| `source` | required | [`MessageSource`](#persistence-type-sha256-b1a819c6930a332d1018838361073d9b3e754476e5dd345d479821554446d644) |
|
||||
| `source` | required | [`MessageSource`](#persistence-type-sha256-40e314bd88730b4bff04a2083cc540ef8659ee2240eeaae0132cac974784b945) |
|
||||
|
||||
Source compatibility: `source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`.
|
||||
|
||||
Attribution-only additions: `tmux-context`, `user-question-reply`.
|
||||
Attribution-only additions: `tmux-context`.
|
||||
|
||||
<a id="persistence-type-sha256-9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7"></a>
|
||||
|
||||
@@ -3865,7 +3841,7 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr
|
||||
| `step` | required | `number` |
|
||||
| `turn` | required | `number` |
|
||||
|
||||
<a id="persistence-type-sha256-453230f850102bbad10d17b227431d749e58d084f8c15a17dd79ce4f100c52ca"></a>
|
||||
<a id="persistence-type-sha256-691c0f641cd3e8072fdacd6fa8c400253cf75a0ce9285d8ce89141bbb24f2fb6"></a>
|
||||
|
||||
<a id="persistence-type-message"></a>
|
||||
|
||||
@@ -3873,15 +3849,15 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr
|
||||
|
||||
### `Message`
|
||||
|
||||
SHA-256: `453230f850102bbad10d17b227431d749e58d084f8c15a17dd79ce4f100c52ca`
|
||||
SHA-256: `691c0f641cd3e8072fdacd6fa8c400253cf75a0ce9285d8ce89141bbb24f2fb6`
|
||||
|
||||
Sources: [`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts)
|
||||
|
||||
One of:
|
||||
|
||||
- [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731)
|
||||
- [`DeveloperMessage`](#persistence-type-sha256-19ac4d9fb655ae9f2d9e8993d083d3eb9aede910d0454b892eb6e1290f55179c)
|
||||
- [`UserMessage`](#persistence-type-sha256-7865a04b33ddf40314f467043e50388a3b6ba3a8b51de7c4ef61ad9910bafcec)
|
||||
- [`DeveloperMessage`](#persistence-type-sha256-f61313bd96ed95b72eb16a4a5a77ad94a0da1756d428d3d660ff6e23c9e6342b)
|
||||
- [`UserMessage`](#persistence-type-sha256-64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586)
|
||||
- [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be)
|
||||
- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363)
|
||||
|
||||
@@ -3958,7 +3934,7 @@ One of:
|
||||
- `"negative"`
|
||||
- `"positive"`
|
||||
|
||||
<a id="persistence-type-sha256-b1a819c6930a332d1018838361073d9b3e754476e5dd345d479821554446d644"></a>
|
||||
<a id="persistence-type-sha256-40e314bd88730b4bff04a2083cc540ef8659ee2240eeaae0132cac974784b945"></a>
|
||||
|
||||
<a id="persistence-type-messagesource"></a>
|
||||
|
||||
@@ -3966,7 +3942,7 @@ One of:
|
||||
|
||||
### `MessageSource`
|
||||
|
||||
SHA-256: `b1a819c6930a332d1018838361073d9b3e754476e5dd345d479821554446d644`
|
||||
SHA-256: `40e314bd88730b4bff04a2083cc540ef8659ee2240eeaae0132cac974784b945`
|
||||
|
||||
Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts)
|
||||
|
||||
@@ -4080,16 +4056,15 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message
|
||||
| `user-approval` | `"relay"` | none | [`{ kind: "user-approval", form: "relay" }`](#persistence-type-sha256-5cdbf342b7748d152bc987b68f58e565e5bba4b227e076e2d2120d87662e45cd) |
|
||||
| `user-approval` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "user-approval", form: "snapshot" }`](#persistence-type-sha256-5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c) |
|
||||
| `user-approval` | not declared | none | [`{ kind: "user-approval" }`](#persistence-type-sha256-6292bcbedaa97feaec5b48a03bb8144b581b93309d9d6c905f26da7a6a7a0a4f) |
|
||||
| `user-question-reply` | not declared | `callId`: `string`; `outcome`: [`union (2 variants)`](#persistence-type-sha256-3e8ed906c20694e13769d14e7f510ea650bc1977d318ad3375eb5e25d75a8dc3) | [`{ kind: "user-question-reply" }`](#persistence-type-sha256-a88baeb50f7e9485fbdc6d4dfaf26d04210be3c699bca84b97a0e2abafc9bd25) |
|
||||
| `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) |
|
||||
|
||||
<a id="persistence-type-sha256-ad6c4e4a7270450d096793e75298c39ec6f91d5e30f211fb8ff24b72695f8dc1"></a>
|
||||
<a id="persistence-type-sha256-fc8242857caf7450d7f68a1f509dce83e237bd21571666006895124cb2246a83"></a>
|
||||
|
||||
### `Message[]`
|
||||
|
||||
SHA-256: `ad6c4e4a7270450d096793e75298c39ec6f91d5e30f211fb8ff24b72695f8dc1`
|
||||
SHA-256: `fc8242857caf7450d7f68a1f509dce83e237bd21571666006895124cb2246a83`
|
||||
|
||||
Array of [`Message`](#persistence-type-sha256-453230f850102bbad10d17b227431d749e58d084f8c15a17dd79ce4f100c52ca).
|
||||
Array of [`Message`](#persistence-type-sha256-691c0f641cd3e8072fdacd6fa8c400253cf75a0ce9285d8ce89141bbb24f2fb6).
|
||||
|
||||
<a id="persistence-type-sha256-719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91"></a>
|
||||
|
||||
@@ -4456,7 +4431,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/
|
||||
| `source` | required | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) |
|
||||
| `title` | required | `string` |
|
||||
|
||||
<a id="persistence-type-sha256-3e94e9061e5069552df9a5568f90c89bec379a9cdf0dc3c122127746312111e9"></a>
|
||||
<a id="persistence-type-sha256-9bc85364d1f37d9c837db6a3bdd3d1d007d923cdc7401a3322be9f12fdc225fd"></a>
|
||||
|
||||
<a id="persistence-type-packagessessionsession-title-llmsrcindextssessiontitlellmrequesteventdata"></a>
|
||||
|
||||
@@ -4464,7 +4439,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/
|
||||
|
||||
### `SessionTitleLlmRequestEventData`
|
||||
|
||||
SHA-256: `3e94e9061e5069552df9a5568f90c89bec379a9cdf0dc3c122127746312111e9`
|
||||
SHA-256: `9bc85364d1f37d9c837db6a3bdd3d1d007d923cdc7401a3322be9f12fdc225fd`
|
||||
|
||||
Sources: [`packages/session/session-title-llm/src/index.ts:34`](../packages/session/session-title-llm/src/index.ts)
|
||||
|
||||
@@ -4472,7 +4447,7 @@ Sources: [`packages/session/session-title-llm/src/index.ts:34`](../packages/sess
|
||||
|---|---|---|
|
||||
| `maxTokens` | required | `number` |
|
||||
| `messageSeqs` | required | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
|
||||
| `messages` | required | [`Message[]`](#persistence-type-sha256-ad6c4e4a7270450d096793e75298c39ec6f91d5e30f211fb8ff24b72695f8dc1) |
|
||||
| `messages` | required | [`Message[]`](#persistence-type-sha256-fc8242857caf7450d7f68a1f509dce83e237bd21571666006895124cb2246a83) |
|
||||
| `route` | required | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) |
|
||||
| `system` | required | `string` |
|
||||
| `titleProvider` | required | `string` |
|
||||
@@ -5126,7 +5101,7 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src
|
||||
| `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) |
|
||||
| `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) |
|
||||
|
||||
<a id="persistence-type-sha256-7865a04b33ddf40314f467043e50388a3b6ba3a8b51de7c4ef61ad9910bafcec"></a>
|
||||
<a id="persistence-type-sha256-64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586"></a>
|
||||
|
||||
<a id="persistence-type-packagesllmllmsrcmessagetsusermessage"></a>
|
||||
|
||||
@@ -5134,7 +5109,7 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src
|
||||
|
||||
### `UserMessage`
|
||||
|
||||
SHA-256: `7865a04b33ddf40314f467043e50388a3b6ba3a8b51de7c4ef61ad9910bafcec`
|
||||
SHA-256: `64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586`
|
||||
|
||||
Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts)
|
||||
|
||||
@@ -5143,19 +5118,19 @@ Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message
|
||||
| `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) |
|
||||
| `id` | required | `string` |
|
||||
| `role` | required | `"user"` |
|
||||
| `source` | required | [`MessageSource`](#persistence-type-sha256-b1a819c6930a332d1018838361073d9b3e754476e5dd345d479821554446d644) |
|
||||
| `source` | required | [`MessageSource`](#persistence-type-sha256-40e314bd88730b4bff04a2083cc540ef8659ee2240eeaae0132cac974784b945) |
|
||||
|
||||
Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`.
|
||||
|
||||
Attribution-only additions: `tmux-context`, `user-question-reply`.
|
||||
Attribution-only additions: `tmux-context`.
|
||||
|
||||
<a id="persistence-type-sha256-94408de87611d1469c83ff7f1a8c6366e4e0ada349ff090c2bd408a024bab080"></a>
|
||||
<a id="persistence-type-sha256-d1f27cf1650e5f59d5d6e95ce6dfd92b31757af076a8ab6418a70b6dbdd53621"></a>
|
||||
|
||||
### `UserMessage[]`
|
||||
|
||||
SHA-256: `94408de87611d1469c83ff7f1a8c6366e4e0ada349ff090c2bd408a024bab080`
|
||||
SHA-256: `d1f27cf1650e5f59d5d6e95ce6dfd92b31757af076a8ab6418a70b6dbdd53621`
|
||||
|
||||
Array of [`UserMessage`](#persistence-type-sha256-7865a04b33ddf40314f467043e50388a3b6ba3a8b51de7c4ef61ad9910bafcec).
|
||||
Array of [`UserMessage`](#persistence-type-sha256-64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586).
|
||||
|
||||
<a id="persistence-type-sha256-c2deb7c9183735d4e0fffddf5c623f4e34daa1c8e96b111982e957dfcbbcf896"></a>
|
||||
|
||||
@@ -5404,17 +5379,6 @@ One of:
|
||||
- `"error"`
|
||||
- `"success"`
|
||||
|
||||
<a id="persistence-type-sha256-3e8ed906c20694e13769d14e7f510ea650bc1977d318ad3375eb5e25d75a8dc3"></a>
|
||||
|
||||
### `union (2 variants)`
|
||||
|
||||
SHA-256: `3e8ed906c20694e13769d14e7f510ea650bc1977d318ad3375eb5e25d75a8dc3`
|
||||
|
||||
One of:
|
||||
|
||||
- `"answered"`
|
||||
- `"dismissed"`
|
||||
|
||||
<a id="persistence-type-sha256-50cf02d875f8a1f1411f4a6fbcffab9120ccef5f74a47331fbf29480e5f68f48"></a>
|
||||
|
||||
### `union (2 variants)`
|
||||
@@ -6009,18 +5973,18 @@ Sources: [`packages/core/session/src/types.ts:390`](../packages/core/session/src
|
||||
| `reason` | required | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) |
|
||||
| `startsSeries` | optional | `true` |
|
||||
|
||||
<a id="persistence-type-sha256-c4d3620755d2defbe487d6e64259886b87b5dcff7bd58c8f9d85a06fa6f9bce0"></a>
|
||||
<a id="persistence-type-sha256-51c78ccc9023ce6642d07fb9a10cbf942ff7e313d5b304ddc77218df71da708f"></a>
|
||||
|
||||
### `{ headerSeq?, message, step, turn }`
|
||||
|
||||
SHA-256: `c4d3620755d2defbe487d6e64259886b87b5dcff7bd58c8f9d85a06fa6f9bce0`
|
||||
SHA-256: `51c78ccc9023ce6642d07fb9a10cbf942ff7e313d5b304ddc77218df71da708f`
|
||||
|
||||
Sources: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
| `headerSeq` | optional | `number` |
|
||||
| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-19ac4d9fb655ae9f2d9e8993d083d3eb9aede910d0454b892eb6e1290f55179c) |
|
||||
| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-f61313bd96ed95b72eb16a4a5a77ad94a0da1756d428d3d660ff6e23c9e6342b) |
|
||||
| `step` | required | `number` |
|
||||
| `turn` | required | `number` |
|
||||
|
||||
@@ -6104,17 +6068,17 @@ Sources: [`packages/core/session/src/types.ts:427`](../packages/core/session/src
|
||||
|---|---|---|
|
||||
| `inherited` | optional | `true` |
|
||||
|
||||
<a id="persistence-type-sha256-b7ae3ff2cea93132e51e04be70f73590c91509e32bcaa34f3260d5503cfa0d3c"></a>
|
||||
<a id="persistence-type-sha256-dc4bd7ac827f4b1f9236f9a157a4b4cc171a0588064a611e43edd95f62c68130"></a>
|
||||
|
||||
### `{ inserted, outcome?, removedCount?, start, … }`
|
||||
|
||||
SHA-256: `b7ae3ff2cea93132e51e04be70f73590c91509e32bcaa34f3260d5503cfa0d3c`
|
||||
SHA-256: `dc4bd7ac827f4b1f9236f9a157a4b4cc171a0588064a611e43edd95f62c68130`
|
||||
|
||||
Sources: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-94408de87611d1469c83ff7f1a8c6366e4e0ada349ff090c2bd408a024bab080) |
|
||||
| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-d1f27cf1650e5f59d5d6e95ce6dfd92b31757af076a8ab6418a70b6dbdd53621) |
|
||||
| `outcome` | optional | `"canceled"` |
|
||||
| `removedCount` | optional | `number` |
|
||||
| `start` | required | `number` |
|
||||
@@ -7373,20 +7337,6 @@ SHA-256: `5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c`
|
||||
| `kind` | required | `"user-approval"` |
|
||||
| `sections` | required | [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) |
|
||||
|
||||
<a id="persistence-type-sha256-a88baeb50f7e9485fbdc6d4dfaf26d04210be3c699bca84b97a0e2abafc9bd25"></a>
|
||||
|
||||
### `{ kind: "user-question-reply" }`
|
||||
|
||||
SHA-256: `a88baeb50f7e9485fbdc6d4dfaf26d04210be3c699bca84b97a0e2abafc9bd25`
|
||||
|
||||
Sources: [`packages/interaction/user-questions/src/types.ts:120`](../packages/interaction/user-questions/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
| `callId` | required | `string` |
|
||||
| `kind` | required | `"user-question-reply"` |
|
||||
| `outcome` | required | [`union (2 variants)`](#persistence-type-sha256-3e8ed906c20694e13769d14e7f510ea650bc1977d318ad3375eb5e25d75a8dc3) |
|
||||
|
||||
<a id="persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94"></a>
|
||||
|
||||
### `{ kind: "webhook", form: "notice" }`
|
||||
@@ -7662,17 +7612,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b`
|
||||
| `time` | required | `number` |
|
||||
| `type` | required | `"agent-preset/selected"` |
|
||||
|
||||
<a id="persistence-type-sha256-99a3e14f0b0ff0e38d345e7cb7d8d81ee8d11c51d611d9f91b1a82c8ea481fc4"></a>
|
||||
<a id="persistence-type-sha256-1506a9b8224986c83015ae99d2cb5ede705538c58c063d6a48ef6d761a31ba6c"></a>
|
||||
|
||||
<a id="persistence-type-eventagentinboxspliced"></a>
|
||||
|
||||
### `{ type: "agent/inbox/spliced" }`
|
||||
|
||||
SHA-256: `99a3e14f0b0ff0e38d345e7cb7d8d81ee8d11c51d611d9f91b1a82c8ea481fc4`
|
||||
SHA-256: `1506a9b8224986c83015ae99d2cb5ede705538c58c063d6a48ef6d761a31ba6c`
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-b7ae3ff2cea93132e51e04be70f73590c91509e32bcaa34f3260d5503cfa0d3c) |
|
||||
| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-dc4bd7ac827f4b1f9236f9a157a4b4cc171a0588064a611e43edd95f62c68130) |
|
||||
| `ignorable` | optional | `true` |
|
||||
| `seq` | required | `number` |
|
||||
| `time` | required | `number` |
|
||||
@@ -7913,17 +7863,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176`
|
||||
| `time` | required | `number` |
|
||||
| `type` | required | `"deliverables/presented"` |
|
||||
|
||||
<a id="persistence-type-sha256-5d588f99e950c42bdd55a27fa41e98f5c86db5b0523622d85b99604c7ddb0107"></a>
|
||||
<a id="persistence-type-sha256-eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80"></a>
|
||||
|
||||
<a id="persistence-type-eventdevelopermessage"></a>
|
||||
|
||||
### `{ type: "developer/message" }`
|
||||
|
||||
SHA-256: `5d588f99e950c42bdd55a27fa41e98f5c86db5b0523622d85b99604c7ddb0107`
|
||||
SHA-256: `eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80`
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-c4d3620755d2defbe487d6e64259886b87b5dcff7bd58c8f9d85a06fa6f9bce0) |
|
||||
| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-51c78ccc9023ce6642d07fb9a10cbf942ff7e313d5b304ddc77218df71da708f) |
|
||||
| `ignorable` | optional | `true` |
|
||||
| `seq` | required | `number` |
|
||||
| `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
|
||||
@@ -8279,17 +8229,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5`
|
||||
| `time` | required | `number` |
|
||||
| `type` | required | `"session/title"` |
|
||||
|
||||
<a id="persistence-type-sha256-64170a859320a56b572372f9ab16e31c4ae9c62454a31418e900ebbf90053c68"></a>
|
||||
<a id="persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d"></a>
|
||||
|
||||
<a id="persistence-type-eventsessiontitle-llm-request"></a>
|
||||
|
||||
### `{ type: "session/title-llm-request" }`
|
||||
|
||||
SHA-256: `64170a859320a56b572372f9ab16e31c4ae9c62454a31418e900ebbf90053c68`
|
||||
SHA-256: `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d`
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-3e94e9061e5069552df9a5568f90c89bec379a9cdf0dc3c122127746312111e9) |
|
||||
| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-9bc85364d1f37d9c837db6a3bdd3d1d007d923cdc7401a3322be9f12fdc225fd) |
|
||||
| `ignorable` | optional | `true` |
|
||||
| `seq` | required | `number` |
|
||||
| `time` | required | `number` |
|
||||
@@ -8712,17 +8662,17 @@ Sources: [`packages/llm/llm/src/types.ts:458`](../packages/llm/llm/src/types.ts)
|
||||
| `type` | required | `"usage"` |
|
||||
| `usage` | required | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) |
|
||||
|
||||
<a id="persistence-type-sha256-1d1697092d04c08f9fcb99015ac082e58b01b7de9e41611fa6aebf2879c3d083"></a>
|
||||
<a id="persistence-type-sha256-3f72db3d87a0c5c43e68be467b4cca728eaf5adc1d5d2b6975ff42bfbd961761"></a>
|
||||
|
||||
<a id="persistence-type-eventusermessage"></a>
|
||||
|
||||
### `{ type: "user/message" }`
|
||||
|
||||
SHA-256: `1d1697092d04c08f9fcb99015ac082e58b01b7de9e41611fa6aebf2879c3d083`
|
||||
SHA-256: `3f72db3d87a0c5c43e68be467b4cca728eaf5adc1d5d2b6975ff42bfbd961761`
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
| `data` | required | [`UserMessage`](#persistence-type-sha256-7865a04b33ddf40314f467043e50388a3b6ba3a8b51de7c4ef61ad9910bafcec) |
|
||||
| `data` | required | [`UserMessage`](#persistence-type-sha256-64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586) |
|
||||
| `ignorable` | optional | `true` |
|
||||
| `seq` | required | `number` |
|
||||
| `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) |
|
||||
| `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) |
|
||||
| `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) |
|
||||
| `event:agent/inbox/spliced` | event | `99a3e14f0b0ff0e38d345e7cb7d8d81ee8d11c51d611d9f91b1a82c8ea481fc4` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-99a3e14f0b0ff0e38d345e7cb7d8d81ee8d11c51d611d9f91b1a82c8ea481fc4) |
|
||||
| `event:agent/inbox/spliced` | event | `1506a9b8224986c83015ae99d2cb5ede705538c58c063d6a48ef6d761a31ba6c` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-1506a9b8224986c83015ae99d2cb5ede705538c58c063d6a48ef6d761a31ba6c) |
|
||||
| `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) |
|
||||
| `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) |
|
||||
| `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) |
|
||||
@@ -36,7 +36,7 @@
|
||||
| `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) |
|
||||
| `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) |
|
||||
| `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) |
|
||||
| `event:developer/message` | event | `5d588f99e950c42bdd55a27fa41e98f5c86db5b0523622d85b99604c7ddb0107` | [`{ type: "developer/message" }`](#persistence-type-sha256-5d588f99e950c42bdd55a27fa41e98f5c86db5b0523622d85b99604c7ddb0107) |
|
||||
| `event:developer/message` | event | `eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80` | [`{ type: "developer/message" }`](#persistence-type-sha256-eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80) |
|
||||
| `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) |
|
||||
| `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) |
|
||||
| `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) |
|
||||
@@ -56,7 +56,7 @@
|
||||
| `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) |
|
||||
| `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) |
|
||||
| `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) |
|
||||
| `event:session/title-llm-request` | event | `64170a859320a56b572372f9ab16e31c4ae9c62454a31418e900ebbf90053c68` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-64170a859320a56b572372f9ab16e31c4ae9c62454a31418e900ebbf90053c68) |
|
||||
| `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) |
|
||||
| `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) |
|
||||
| `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) |
|
||||
| `event:subagent/catalog` | event | `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604) |
|
||||
@@ -78,7 +78,7 @@
|
||||
| `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) |
|
||||
| `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) |
|
||||
| `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) |
|
||||
| `event:user/message` | event | `1d1697092d04c08f9fcb99015ac082e58b01b7de9e41611fa6aebf2879c3d083` | [`{ type: "user/message" }`](#persistence-type-sha256-1d1697092d04c08f9fcb99015ac082e58b01b7de9e41611fa6aebf2879c3d083) |
|
||||
| `event:user/message` | event | `3f72db3d87a0c5c43e68be467b4cca728eaf5adc1d5d2b6975ff42bfbd961761` | [`{ type: "user/message" }`](#persistence-type-sha256-3f72db3d87a0c5c43e68be467b4cca728eaf5adc1d5d2b6975ff42bfbd961761) |
|
||||
| `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331) |
|
||||
| `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) |
|
||||
|
||||
@@ -1359,14 +1359,6 @@ SHA-256: `142c37d302aa22a0a57915b5255939d3ca91c1811ca13fddcf84cf76ea00758a`
|
||||
|
||||
`"always"`
|
||||
|
||||
<a id="persistence-type-sha256-85d6d96ff884297410ec0026bdacba835f10e56cbcc5a3b9d670739f4aa0494b"></a>
|
||||
|
||||
### `"answered"`
|
||||
|
||||
SHA-256: `85d6d96ff884297410ec0026bdacba835f10e56cbcc5a3b9d670739f4aa0494b`
|
||||
|
||||
`"answered"`
|
||||
|
||||
<a id="persistence-type-sha256-e438a9d4fb883b3e4a8d9fc8913aa1550ed50eb384bfbf943100a5605b537498"></a>
|
||||
|
||||
### `"append"`
|
||||
@@ -1687,14 +1679,6 @@ SHA-256: `1379f0b72275994d20f1ddca2d60a96ab16ec80397ca9f1af21bdc67ba5c269c`
|
||||
|
||||
`"developer/message"`
|
||||
|
||||
<a id="persistence-type-sha256-39a6d6b657a33d9e62e6fe1ce35b44df38b6a0fef72a87ee45ec318ebfe8f4b6"></a>
|
||||
|
||||
### `"dismissed"`
|
||||
|
||||
SHA-256: `39a6d6b657a33d9e62e6fe1ce35b44df38b6a0fef72a87ee45ec318ebfe8f4b6`
|
||||
|
||||
`"dismissed"`
|
||||
|
||||
<a id="persistence-type-sha256-26c879bdd130e8b8683a6ce588414fad2244783d6573ca399fa0de184a2b0bb1"></a>
|
||||
|
||||
### `"dispatch"`
|
||||
@@ -2837,14 +2821,6 @@ SHA-256: `f68c335306535a001127dae6685ece493a0ee7f4e2a851197b6054d8a9ff7394`
|
||||
|
||||
`"user-approval"`
|
||||
|
||||
<a id="persistence-type-sha256-9645cf93769a6dfb227bd400bd6cb9fa7bab9140f4a851e4ae92514ade6e3114"></a>
|
||||
|
||||
### `"user-question-reply"`
|
||||
|
||||
SHA-256: `9645cf93769a6dfb227bd400bd6cb9fa7bab9140f4a851e4ae92514ade6e3114`
|
||||
|
||||
`"user-question-reply"`
|
||||
|
||||
<a id="persistence-type-sha256-de25352d2dc4196309bd84b5bac6319f6d5c898ea0e82bb5ae66c5738d0d426f"></a>
|
||||
|
||||
### `"user/message"`
|
||||
@@ -3233,7 +3209,7 @@ SHA-256: `2517ba143a271508d3ca35126d5aca8f7f1facf5aaddce4adf011832042fa2b9`
|
||||
| `body` | 必需 | [`{ max_tokens, messages, model, tools }`](#persistence-type-sha256-930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731) |
|
||||
| `endpoint` | 必需 | `string` |
|
||||
|
||||
<a id="persistence-type-sha256-19ac4d9fb655ae9f2d9e8993d083d3eb9aede910d0454b892eb6e1290f55179c"></a>
|
||||
<a id="persistence-type-sha256-f61313bd96ed95b72eb16a4a5a77ad94a0da1756d428d3d660ff6e23c9e6342b"></a>
|
||||
|
||||
<a id="persistence-type-developermessage"></a>
|
||||
|
||||
@@ -3241,7 +3217,7 @@ SHA-256: `2517ba143a271508d3ca35126d5aca8f7f1facf5aaddce4adf011832042fa2b9`
|
||||
|
||||
### `DeveloperMessage`
|
||||
|
||||
SHA-256: `19ac4d9fb655ae9f2d9e8993d083d3eb9aede910d0454b892eb6e1290f55179c`
|
||||
SHA-256: `f61313bd96ed95b72eb16a4a5a77ad94a0da1756d428d3d660ff6e23c9e6342b`
|
||||
|
||||
来源:[`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts)
|
||||
|
||||
@@ -3250,11 +3226,11 @@ SHA-256: `19ac4d9fb655ae9f2d9e8993d083d3eb9aede910d0454b892eb6e1290f55179c`
|
||||
| `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) |
|
||||
| `id` | 必需 | `string` |
|
||||
| `role` | 必需 | `"developer"` |
|
||||
| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b1a819c6930a332d1018838361073d9b3e754476e5dd345d479821554446d644) |
|
||||
| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-40e314bd88730b4bff04a2083cc540ef8659ee2240eeaae0132cac974784b945) |
|
||||
|
||||
消息源兼容策略:`source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`.
|
||||
|
||||
仅表示归属的新增 kind:`tmux-context`, `user-question-reply`.
|
||||
仅表示归属的新增 kind:`tmux-context`.
|
||||
|
||||
<a id="persistence-type-sha256-9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7"></a>
|
||||
|
||||
@@ -3867,7 +3843,7 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20`
|
||||
| `step` | 必需 | `number` |
|
||||
| `turn` | 必需 | `number` |
|
||||
|
||||
<a id="persistence-type-sha256-453230f850102bbad10d17b227431d749e58d084f8c15a17dd79ce4f100c52ca"></a>
|
||||
<a id="persistence-type-sha256-691c0f641cd3e8072fdacd6fa8c400253cf75a0ce9285d8ce89141bbb24f2fb6"></a>
|
||||
|
||||
<a id="persistence-type-message"></a>
|
||||
|
||||
@@ -3875,15 +3851,15 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20`
|
||||
|
||||
### `Message`
|
||||
|
||||
SHA-256: `453230f850102bbad10d17b227431d749e58d084f8c15a17dd79ce4f100c52ca`
|
||||
SHA-256: `691c0f641cd3e8072fdacd6fa8c400253cf75a0ce9285d8ce89141bbb24f2fb6`
|
||||
|
||||
来源:[`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts)
|
||||
|
||||
以下类型之一:
|
||||
|
||||
- [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731)
|
||||
- [`DeveloperMessage`](#persistence-type-sha256-19ac4d9fb655ae9f2d9e8993d083d3eb9aede910d0454b892eb6e1290f55179c)
|
||||
- [`UserMessage`](#persistence-type-sha256-7865a04b33ddf40314f467043e50388a3b6ba3a8b51de7c4ef61ad9910bafcec)
|
||||
- [`DeveloperMessage`](#persistence-type-sha256-f61313bd96ed95b72eb16a4a5a77ad94a0da1756d428d3d660ff6e23c9e6342b)
|
||||
- [`UserMessage`](#persistence-type-sha256-64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586)
|
||||
- [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be)
|
||||
- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363)
|
||||
|
||||
@@ -3960,7 +3936,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2`
|
||||
- `"negative"`
|
||||
- `"positive"`
|
||||
|
||||
<a id="persistence-type-sha256-b1a819c6930a332d1018838361073d9b3e754476e5dd345d479821554446d644"></a>
|
||||
<a id="persistence-type-sha256-40e314bd88730b4bff04a2083cc540ef8659ee2240eeaae0132cac974784b945"></a>
|
||||
|
||||
<a id="persistence-type-messagesource"></a>
|
||||
|
||||
@@ -3968,7 +3944,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2`
|
||||
|
||||
### `MessageSource`
|
||||
|
||||
SHA-256: `b1a819c6930a332d1018838361073d9b3e754476e5dd345d479821554446d644`
|
||||
SHA-256: `40e314bd88730b4bff04a2083cc540ef8659ee2240eeaae0132cac974784b945`
|
||||
|
||||
来源:[`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts)
|
||||
|
||||
@@ -4082,16 +4058,15 @@ SHA-256: `b1a819c6930a332d1018838361073d9b3e754476e5dd345d479821554446d644`
|
||||
| `user-approval` | `"relay"` | 无 | [`{ kind: "user-approval", form: "relay" }`](#persistence-type-sha256-5cdbf342b7748d152bc987b68f58e565e5bba4b227e076e2d2120d87662e45cd) |
|
||||
| `user-approval` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "user-approval", form: "snapshot" }`](#persistence-type-sha256-5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c) |
|
||||
| `user-approval` | 未声明 | 无 | [`{ kind: "user-approval" }`](#persistence-type-sha256-6292bcbedaa97feaec5b48a03bb8144b581b93309d9d6c905f26da7a6a7a0a4f) |
|
||||
| `user-question-reply` | 未声明 | `callId`: `string`; `outcome`: [`union (2 variants)`](#persistence-type-sha256-3e8ed906c20694e13769d14e7f510ea650bc1977d318ad3375eb5e25d75a8dc3) | [`{ kind: "user-question-reply" }`](#persistence-type-sha256-a88baeb50f7e9485fbdc6d4dfaf26d04210be3c699bca84b97a0e2abafc9bd25) |
|
||||
| `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) |
|
||||
|
||||
<a id="persistence-type-sha256-ad6c4e4a7270450d096793e75298c39ec6f91d5e30f211fb8ff24b72695f8dc1"></a>
|
||||
<a id="persistence-type-sha256-fc8242857caf7450d7f68a1f509dce83e237bd21571666006895124cb2246a83"></a>
|
||||
|
||||
### `Message[]`
|
||||
|
||||
SHA-256: `ad6c4e4a7270450d096793e75298c39ec6f91d5e30f211fb8ff24b72695f8dc1`
|
||||
SHA-256: `fc8242857caf7450d7f68a1f509dce83e237bd21571666006895124cb2246a83`
|
||||
|
||||
[`Message`](#persistence-type-sha256-453230f850102bbad10d17b227431d749e58d084f8c15a17dd79ce4f100c52ca) 的数组。
|
||||
[`Message`](#persistence-type-sha256-691c0f641cd3e8072fdacd6fa8c400253cf75a0ce9285d8ce89141bbb24f2fb6) 的数组。
|
||||
|
||||
<a id="persistence-type-sha256-719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91"></a>
|
||||
|
||||
@@ -4458,7 +4433,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4`
|
||||
| `source` | 必需 | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) |
|
||||
| `title` | 必需 | `string` |
|
||||
|
||||
<a id="persistence-type-sha256-3e94e9061e5069552df9a5568f90c89bec379a9cdf0dc3c122127746312111e9"></a>
|
||||
<a id="persistence-type-sha256-9bc85364d1f37d9c837db6a3bdd3d1d007d923cdc7401a3322be9f12fdc225fd"></a>
|
||||
|
||||
<a id="persistence-type-packagessessionsession-title-llmsrcindextssessiontitlellmrequesteventdata"></a>
|
||||
|
||||
@@ -4466,7 +4441,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4`
|
||||
|
||||
### `SessionTitleLlmRequestEventData`
|
||||
|
||||
SHA-256: `3e94e9061e5069552df9a5568f90c89bec379a9cdf0dc3c122127746312111e9`
|
||||
SHA-256: `9bc85364d1f37d9c837db6a3bdd3d1d007d923cdc7401a3322be9f12fdc225fd`
|
||||
|
||||
来源:[`packages/session/session-title-llm/src/index.ts:34`](../packages/session/session-title-llm/src/index.ts)
|
||||
|
||||
@@ -4474,7 +4449,7 @@ SHA-256: `3e94e9061e5069552df9a5568f90c89bec379a9cdf0dc3c122127746312111e9`
|
||||
|---|---|---|
|
||||
| `maxTokens` | 必需 | `number` |
|
||||
| `messageSeqs` | 必需 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
|
||||
| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-ad6c4e4a7270450d096793e75298c39ec6f91d5e30f211fb8ff24b72695f8dc1) |
|
||||
| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-fc8242857caf7450d7f68a1f509dce83e237bd21571666006895124cb2246a83) |
|
||||
| `route` | 必需 | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) |
|
||||
| `system` | 必需 | `string` |
|
||||
| `titleProvider` | 必需 | `string` |
|
||||
@@ -5128,7 +5103,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402`
|
||||
| `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) |
|
||||
| `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) |
|
||||
|
||||
<a id="persistence-type-sha256-7865a04b33ddf40314f467043e50388a3b6ba3a8b51de7c4ef61ad9910bafcec"></a>
|
||||
<a id="persistence-type-sha256-64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586"></a>
|
||||
|
||||
<a id="persistence-type-packagesllmllmsrcmessagetsusermessage"></a>
|
||||
|
||||
@@ -5136,7 +5111,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402`
|
||||
|
||||
### `UserMessage`
|
||||
|
||||
SHA-256: `7865a04b33ddf40314f467043e50388a3b6ba3a8b51de7c4ef61ad9910bafcec`
|
||||
SHA-256: `64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586`
|
||||
|
||||
来源:[`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts)
|
||||
|
||||
@@ -5145,19 +5120,19 @@ SHA-256: `7865a04b33ddf40314f467043e50388a3b6ba3a8b51de7c4ef61ad9910bafcec`
|
||||
| `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) |
|
||||
| `id` | 必需 | `string` |
|
||||
| `role` | 必需 | `"user"` |
|
||||
| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b1a819c6930a332d1018838361073d9b3e754476e5dd345d479821554446d644) |
|
||||
| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-40e314bd88730b4bff04a2083cc540ef8659ee2240eeaae0132cac974784b945) |
|
||||
|
||||
消息源兼容策略:`source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`.
|
||||
|
||||
仅表示归属的新增 kind:`tmux-context`, `user-question-reply`.
|
||||
仅表示归属的新增 kind:`tmux-context`.
|
||||
|
||||
<a id="persistence-type-sha256-94408de87611d1469c83ff7f1a8c6366e4e0ada349ff090c2bd408a024bab080"></a>
|
||||
<a id="persistence-type-sha256-d1f27cf1650e5f59d5d6e95ce6dfd92b31757af076a8ab6418a70b6dbdd53621"></a>
|
||||
|
||||
### `UserMessage[]`
|
||||
|
||||
SHA-256: `94408de87611d1469c83ff7f1a8c6366e4e0ada349ff090c2bd408a024bab080`
|
||||
SHA-256: `d1f27cf1650e5f59d5d6e95ce6dfd92b31757af076a8ab6418a70b6dbdd53621`
|
||||
|
||||
[`UserMessage`](#persistence-type-sha256-7865a04b33ddf40314f467043e50388a3b6ba3a8b51de7c4ef61ad9910bafcec) 的数组。
|
||||
[`UserMessage`](#persistence-type-sha256-64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586) 的数组。
|
||||
|
||||
<a id="persistence-type-sha256-c2deb7c9183735d4e0fffddf5c623f4e34daa1c8e96b111982e957dfcbbcf896"></a>
|
||||
|
||||
@@ -5406,17 +5381,6 @@ SHA-256: `1ab2ef1296f157ba96998f179bb5daa9d21a82480da2901127aeab2730797c92`
|
||||
- `"error"`
|
||||
- `"success"`
|
||||
|
||||
<a id="persistence-type-sha256-3e8ed906c20694e13769d14e7f510ea650bc1977d318ad3375eb5e25d75a8dc3"></a>
|
||||
|
||||
### `union (2 variants)`
|
||||
|
||||
SHA-256: `3e8ed906c20694e13769d14e7f510ea650bc1977d318ad3375eb5e25d75a8dc3`
|
||||
|
||||
以下类型之一:
|
||||
|
||||
- `"answered"`
|
||||
- `"dismissed"`
|
||||
|
||||
<a id="persistence-type-sha256-50cf02d875f8a1f1411f4a6fbcffab9120ccef5f74a47331fbf29480e5f68f48"></a>
|
||||
|
||||
### `union (2 variants)`
|
||||
@@ -6011,18 +5975,18 @@ SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585`
|
||||
| `reason` | 必需 | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) |
|
||||
| `startsSeries` | 可选 | `true` |
|
||||
|
||||
<a id="persistence-type-sha256-c4d3620755d2defbe487d6e64259886b87b5dcff7bd58c8f9d85a06fa6f9bce0"></a>
|
||||
<a id="persistence-type-sha256-51c78ccc9023ce6642d07fb9a10cbf942ff7e313d5b304ddc77218df71da708f"></a>
|
||||
|
||||
### `{ headerSeq?, message, step, turn }`
|
||||
|
||||
SHA-256: `c4d3620755d2defbe487d6e64259886b87b5dcff7bd58c8f9d85a06fa6f9bce0`
|
||||
SHA-256: `51c78ccc9023ce6642d07fb9a10cbf942ff7e313d5b304ddc77218df71da708f`
|
||||
|
||||
来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
| `headerSeq` | 可选 | `number` |
|
||||
| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-19ac4d9fb655ae9f2d9e8993d083d3eb9aede910d0454b892eb6e1290f55179c) |
|
||||
| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-f61313bd96ed95b72eb16a4a5a77ad94a0da1756d428d3d660ff6e23c9e6342b) |
|
||||
| `step` | 必需 | `number` |
|
||||
| `turn` | 必需 | `number` |
|
||||
|
||||
@@ -6106,17 +6070,17 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3`
|
||||
|---|---|---|
|
||||
| `inherited` | 可选 | `true` |
|
||||
|
||||
<a id="persistence-type-sha256-b7ae3ff2cea93132e51e04be70f73590c91509e32bcaa34f3260d5503cfa0d3c"></a>
|
||||
<a id="persistence-type-sha256-dc4bd7ac827f4b1f9236f9a157a4b4cc171a0588064a611e43edd95f62c68130"></a>
|
||||
|
||||
### `{ inserted, outcome?, removedCount?, start, … }`
|
||||
|
||||
SHA-256: `b7ae3ff2cea93132e51e04be70f73590c91509e32bcaa34f3260d5503cfa0d3c`
|
||||
SHA-256: `dc4bd7ac827f4b1f9236f9a157a4b4cc171a0588064a611e43edd95f62c68130`
|
||||
|
||||
来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-94408de87611d1469c83ff7f1a8c6366e4e0ada349ff090c2bd408a024bab080) |
|
||||
| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-d1f27cf1650e5f59d5d6e95ce6dfd92b31757af076a8ab6418a70b6dbdd53621) |
|
||||
| `outcome` | 可选 | `"canceled"` |
|
||||
| `removedCount` | 可选 | `number` |
|
||||
| `start` | 必需 | `number` |
|
||||
@@ -7375,20 +7339,6 @@ SHA-256: `5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c`
|
||||
| `kind` | 必需 | `"user-approval"` |
|
||||
| `sections` | 必需 | [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) |
|
||||
|
||||
<a id="persistence-type-sha256-a88baeb50f7e9485fbdc6d4dfaf26d04210be3c699bca84b97a0e2abafc9bd25"></a>
|
||||
|
||||
### `{ kind: "user-question-reply" }`
|
||||
|
||||
SHA-256: `a88baeb50f7e9485fbdc6d4dfaf26d04210be3c699bca84b97a0e2abafc9bd25`
|
||||
|
||||
来源:[`packages/interaction/user-questions/src/types.ts:120`](../packages/interaction/user-questions/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
| `callId` | 必需 | `string` |
|
||||
| `kind` | 必需 | `"user-question-reply"` |
|
||||
| `outcome` | 必需 | [`union (2 variants)`](#persistence-type-sha256-3e8ed906c20694e13769d14e7f510ea650bc1977d318ad3375eb5e25d75a8dc3) |
|
||||
|
||||
<a id="persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94"></a>
|
||||
|
||||
### `{ kind: "webhook", form: "notice" }`
|
||||
@@ -7664,17 +7614,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b`
|
||||
| `time` | 必需 | `number` |
|
||||
| `type` | 必需 | `"agent-preset/selected"` |
|
||||
|
||||
<a id="persistence-type-sha256-99a3e14f0b0ff0e38d345e7cb7d8d81ee8d11c51d611d9f91b1a82c8ea481fc4"></a>
|
||||
<a id="persistence-type-sha256-1506a9b8224986c83015ae99d2cb5ede705538c58c063d6a48ef6d761a31ba6c"></a>
|
||||
|
||||
<a id="persistence-type-eventagentinboxspliced"></a>
|
||||
|
||||
### `{ type: "agent/inbox/spliced" }`
|
||||
|
||||
SHA-256: `99a3e14f0b0ff0e38d345e7cb7d8d81ee8d11c51d611d9f91b1a82c8ea481fc4`
|
||||
SHA-256: `1506a9b8224986c83015ae99d2cb5ede705538c58c063d6a48ef6d761a31ba6c`
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-b7ae3ff2cea93132e51e04be70f73590c91509e32bcaa34f3260d5503cfa0d3c) |
|
||||
| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-dc4bd7ac827f4b1f9236f9a157a4b4cc171a0588064a611e43edd95f62c68130) |
|
||||
| `ignorable` | 可选 | `true` |
|
||||
| `seq` | 必需 | `number` |
|
||||
| `time` | 必需 | `number` |
|
||||
@@ -7915,17 +7865,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176`
|
||||
| `time` | 必需 | `number` |
|
||||
| `type` | 必需 | `"deliverables/presented"` |
|
||||
|
||||
<a id="persistence-type-sha256-5d588f99e950c42bdd55a27fa41e98f5c86db5b0523622d85b99604c7ddb0107"></a>
|
||||
<a id="persistence-type-sha256-eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80"></a>
|
||||
|
||||
<a id="persistence-type-eventdevelopermessage"></a>
|
||||
|
||||
### `{ type: "developer/message" }`
|
||||
|
||||
SHA-256: `5d588f99e950c42bdd55a27fa41e98f5c86db5b0523622d85b99604c7ddb0107`
|
||||
SHA-256: `eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80`
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-c4d3620755d2defbe487d6e64259886b87b5dcff7bd58c8f9d85a06fa6f9bce0) |
|
||||
| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-51c78ccc9023ce6642d07fb9a10cbf942ff7e313d5b304ddc77218df71da708f) |
|
||||
| `ignorable` | 可选 | `true` |
|
||||
| `seq` | 必需 | `number` |
|
||||
| `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
|
||||
@@ -8281,17 +8231,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5`
|
||||
| `time` | 必需 | `number` |
|
||||
| `type` | 必需 | `"session/title"` |
|
||||
|
||||
<a id="persistence-type-sha256-64170a859320a56b572372f9ab16e31c4ae9c62454a31418e900ebbf90053c68"></a>
|
||||
<a id="persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d"></a>
|
||||
|
||||
<a id="persistence-type-eventsessiontitle-llm-request"></a>
|
||||
|
||||
### `{ type: "session/title-llm-request" }`
|
||||
|
||||
SHA-256: `64170a859320a56b572372f9ab16e31c4ae9c62454a31418e900ebbf90053c68`
|
||||
SHA-256: `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d`
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-3e94e9061e5069552df9a5568f90c89bec379a9cdf0dc3c122127746312111e9) |
|
||||
| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-9bc85364d1f37d9c837db6a3bdd3d1d007d923cdc7401a3322be9f12fdc225fd) |
|
||||
| `ignorable` | 可选 | `true` |
|
||||
| `seq` | 必需 | `number` |
|
||||
| `time` | 必需 | `number` |
|
||||
@@ -8714,17 +8664,17 @@ SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515`
|
||||
| `type` | 必需 | `"usage"` |
|
||||
| `usage` | 必需 | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) |
|
||||
|
||||
<a id="persistence-type-sha256-1d1697092d04c08f9fcb99015ac082e58b01b7de9e41611fa6aebf2879c3d083"></a>
|
||||
<a id="persistence-type-sha256-3f72db3d87a0c5c43e68be467b4cca728eaf5adc1d5d2b6975ff42bfbd961761"></a>
|
||||
|
||||
<a id="persistence-type-eventusermessage"></a>
|
||||
|
||||
### `{ type: "user/message" }`
|
||||
|
||||
SHA-256: `1d1697092d04c08f9fcb99015ac082e58b01b7de9e41611fa6aebf2879c3d083`
|
||||
SHA-256: `3f72db3d87a0c5c43e68be467b4cca728eaf5adc1d5d2b6975ff42bfbd961761`
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-7865a04b33ddf40314f467043e50388a3b6ba3a8b51de7c4ef61ad9910bafcec) |
|
||||
| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-64ea5653002834c44f29bea4620e9c42aeb6024ae5b1d58d69f95158006b2586) |
|
||||
| `ignorable` | 可选 | `true` |
|
||||
| `seq` | 必需 | `number` |
|
||||
| `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) |
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Bilingual-pair consistency record for 2026-09-21-user-question-reply.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-21-user-question-reply.md
|
||||
/:
|
||||
en: 3d5a424ae7d7e46b
|
||||
zh: c22a8e4a62d8cc1a
|
||||
/2026-09-21-user-question-reply:
|
||||
en: de475e4778124528
|
||||
zh: 2b619b0c0f3a6a6e
|
||||
/2026-09-21-user-question-reply/summary:
|
||||
en: 1c7af44ba7eb2983
|
||||
zh: 05ae869290bb641d
|
||||
/2026-09-21-user-question-reply/table-of-contents:
|
||||
en: 6cf314a069ceb451
|
||||
zh: 9adff678e9ecab3b
|
||||
/2026-09-21-user-question-reply/declaration:
|
||||
en: 6907136f28e48c90
|
||||
zh: 02f8298485f3d8e7
|
||||
/2026-09-21-user-question-reply/compatibility:
|
||||
en: 48563f61d63b6259
|
||||
zh: da7dbdc92f73fed3
|
||||
/2026-09-21-user-question-reply/verification:
|
||||
en: c66824c0cdb87d86
|
||||
zh: d88c29df87fc607b
|
||||
/2026-09-21-user-question-reply/dev-note:
|
||||
en: b75c8458928b72b9
|
||||
zh: 003ba4e17b4b5269
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
description: "Records a persistence type transition and its compatibility acknowledgement."
|
||||
kind: persistence-change
|
||||
---
|
||||
|
||||
# 2026-09-21-user-question-reply
|
||||
|
||||
English | [中文](2026-09-21-user-question-reply.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Adds the qualified user-question-reply message source that carries a late answer or dismissal of a continued ask_user_question call into the agent inbox.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Declaration](#declaration)
|
||||
- [Compatibility](#compatibility)
|
||||
- [Verification](#verification)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
<a id="declaration"></a>
|
||||
## Declaration
|
||||
|
||||
```yaml persistence-change
|
||||
schemaVersion: 1
|
||||
id: 2026-09-21-user-question-reply
|
||||
baseline: false
|
||||
changes:
|
||||
- root: "event:agent/inbox/spliced"
|
||||
previous: "2026-09-16-session-format-v4"
|
||||
after: "99a3e14f0b0ff0e38d345e7cb7d8d81ee8d11c51d611d9f91b1a82c8ea481fc4"
|
||||
decision: same-version
|
||||
- root: "event:developer/message"
|
||||
previous: "2026-09-16-session-format-v4"
|
||||
after: "5d588f99e950c42bdd55a27fa41e98f5c86db5b0523622d85b99604c7ddb0107"
|
||||
decision: same-version
|
||||
- root: "event:session/title-llm-request"
|
||||
previous: "2026-09-16-session-format-v4"
|
||||
after: "64170a859320a56b572372f9ab16e31c4ae9c62454a31418e900ebbf90053c68"
|
||||
decision: same-version
|
||||
- root: "event:user/message"
|
||||
previous: "2026-09-16-session-format-v4"
|
||||
after: "1d1697092d04c08f9fcb99015ac082e58b01b7de9e41611fa6aebf2879c3d083"
|
||||
decision: same-version
|
||||
```
|
||||
|
||||
<a id="compatibility"></a>
|
||||
## Compatibility
|
||||
|
||||
Existing logs contain no such source and remain valid. The kind is a qualified attribution: a reader without dsh-user-questions preserves the user message and its callId and outcome metadata unchanged and derives history from the message content alone. Only the producer's userQuestions projection reads the kind, to close the question it names; it imposes no validation, replay, or authority requirement. No event type is added and the Session header is unchanged.
|
||||
|
||||
<a id="verification"></a>
|
||||
## Verification
|
||||
|
||||
pnpm run typecheck: Host and Client faces pass. pnpm --silent run verify-persistence-changes --json: the four affected roots classify as attribution-kind-added without a version bump. Projection and Remote behavior tests are pending.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
## Dev Note
|
||||
|
||||
None.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,60 +0,0 @@
|
||||
---
|
||||
description: "记录持久化类型更改及其兼容性确认。"
|
||||
kind: persistence-change
|
||||
---
|
||||
|
||||
# 2026-09-21-user-question-reply
|
||||
|
||||
[English](2026-09-21-user-question-reply.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
新增受限定的 user-question-reply 消息来源,把已继续的 ask_user_question 调用的迟到回答或放弃送入 agent inbox。
|
||||
|
||||
## 目录
|
||||
|
||||
- [声明](#declaration)
|
||||
- [兼容性](#compatibility)
|
||||
- [验证](#verification)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
<a id="declaration"></a>
|
||||
## 声明
|
||||
|
||||
```yaml persistence-change
|
||||
schemaVersion: 1
|
||||
id: 2026-09-21-user-question-reply
|
||||
baseline: false
|
||||
changes:
|
||||
- root: "event:agent/inbox/spliced"
|
||||
previous: "2026-09-16-session-format-v4"
|
||||
after: "99a3e14f0b0ff0e38d345e7cb7d8d81ee8d11c51d611d9f91b1a82c8ea481fc4"
|
||||
decision: same-version
|
||||
- root: "event:developer/message"
|
||||
previous: "2026-09-16-session-format-v4"
|
||||
after: "5d588f99e950c42bdd55a27fa41e98f5c86db5b0523622d85b99604c7ddb0107"
|
||||
decision: same-version
|
||||
- root: "event:session/title-llm-request"
|
||||
previous: "2026-09-16-session-format-v4"
|
||||
after: "64170a859320a56b572372f9ab16e31c4ae9c62454a31418e900ebbf90053c68"
|
||||
decision: same-version
|
||||
- root: "event:user/message"
|
||||
previous: "2026-09-16-session-format-v4"
|
||||
after: "1d1697092d04c08f9fcb99015ac082e58b01b7de9e41611fa6aebf2879c3d083"
|
||||
decision: same-version
|
||||
```
|
||||
|
||||
<a id="compatibility"></a>
|
||||
## 兼容性
|
||||
|
||||
已有日志不含该来源,仍然有效。该类型是受限定的归属:没有 dsh-user-questions 的读取方原样保留这条用户消息及其 callId 与 outcome 元数据,仅凭消息内容推导历史。只有生产方的 userQuestions projection 读取该类型,用来关闭它指名的问题;它不施加任何校验、回放或权限要求。不新增事件类型,Session header 不变。
|
||||
|
||||
<a id="verification"></a>
|
||||
## 验证
|
||||
|
||||
pnpm run typecheck:Host 与 Client 两个 face 通过。pnpm --silent run verify-persistence-changes --json:四个受影响的根均判为 attribution-kind-added,无需版本升级。projection 与 Remote 的行为测试待补。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
## 开发备注
|
||||
|
||||
无。
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 14c57f30e676f6e3
|
||||
zh: b1a0ccf26fab3727
|
||||
/session-persistence-formats/format-references:
|
||||
en: 289041c0dac8994b
|
||||
zh: a18be2ba190635d4
|
||||
en: 45dba01dd6d69c80
|
||||
zh: c366d1a3991118c9
|
||||
/session-persistence-formats/scope-and-evidence:
|
||||
en: b220062139ddac1e
|
||||
zh: 7c4c0efe05dafa9c
|
||||
|
||||
@@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant.
|
||||
| 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 |
|
||||
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 |
|
||||
| 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 |
|
||||
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 592 |
|
||||
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 587 |
|
||||
|
||||
<!-- persistence-format-index:end -->
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式
|
||||
| 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 |
|
||||
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 |
|
||||
| 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 |
|
||||
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 592 |
|
||||
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 587 |
|
||||
|
||||
<!-- persistence-format-index:end -->
|
||||
|
||||
|
||||
Generated
+39688
-40529
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,7 @@
|
||||
zh: 8e1d2627823b1ffc
|
||||
/user-interaction/presentation-intent:
|
||||
en: aca3811ea3f0c2ef
|
||||
zh: 6cf2aea40c7dece8
|
||||
zh: 91f93dc33d12e343
|
||||
/user-interaction/question-item:
|
||||
en: 1ad4373e6fdfe4c7
|
||||
zh: efb8d41ebe7224f8
|
||||
@@ -18,11 +18,8 @@
|
||||
en: e10cf6c8c9c20962
|
||||
zh: 0b347b40c12b6b25
|
||||
/user-interaction/answer:
|
||||
en: efd2dbb95a885d0e
|
||||
zh: 7a20c75c4ea6a9e0
|
||||
/user-interaction/timed-questions-and-late-replies:
|
||||
en: 1bca7bea5edef782
|
||||
zh: 43d5dd2ea0ab74bf
|
||||
en: 4235b687a9975881
|
||||
zh: 350d000d4bfa7400
|
||||
/user-interaction/errors:
|
||||
en: 5762b1498a426ab6
|
||||
zh: bb9987ec82103620
|
||||
en: 33cc3bf5e352e223
|
||||
zh: 143279cc3f355453
|
||||
@@ -103,92 +103,9 @@ interface AskUserQuestionAnswer {
|
||||
}
|
||||
```
|
||||
|
||||
Timed answerers receive `wait: { callId, timed: true }`. The call id identifies the card and the foreground wait. The Client calls `attachWait` to claim the wait and obtain its remaining duration; transport forwards the original event without inspecting or rewriting these fields. A `wait` without `timed` identifies an indefinite card-keyed request. An absent `wait` identifies a blocking request without a card key.
|
||||
|
||||
```ts type-equiv
|
||||
/** Client-safe payload declared for the user-question answerer waterfall. */
|
||||
interface AskUserQuestionRequestEvent {
|
||||
/** Questions to display. */
|
||||
questions: AskUserQuestionItem[]
|
||||
/** Agent identity projected to the corresponding Client Context in transit. */
|
||||
agent?: Agent
|
||||
/** Cancellation lifetime of the pending request. */
|
||||
signal?: AbortSignal
|
||||
/**
|
||||
* Tool call the Client card is keyed by. Timed answerers attach to the
|
||||
* business wait stream before starting their local countdown.
|
||||
*/
|
||||
wait?: {
|
||||
/** Tool call the Client card is keyed by. */
|
||||
callId: ToolCallId
|
||||
/** True for a foreground wait that requires a Client claim; absent for indefinite waits. */
|
||||
timed?: boolean
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Timed questions and late replies
|
||||
|
||||
A timed question waits for a bounded foreground window and then lets the agent continue independent work. If the user answers inside the window, the tool returns the answer batch; otherwise `TimedUserQuestionResult` is `{ pending: true, callId }`. Pending means the question is still answerable; it is not an empty answer, a refusal, a confirmation, or permission.
|
||||
|
||||
```ts type-equiv
|
||||
/** Timed ask result returned when the foreground answer window closes. */
|
||||
type TimedUserQuestionResult = AskUserQuestionAnswer | { pending: true; callId: ToolCallId }
|
||||
```
|
||||
|
||||
A Client claim is a business Remote stream, not a Gateway delivery. `TimedQuestionWait` holds the original Host deadline and runs its timer only while no claim exists. Its `attachWait` stream yields the current remaining duration, and the Client counts down with its own clock. Focus, editing, and Take time stay local. Stream cancellation releases that claim; after the last claim leaves, the Host resumes the original deadline. An unattended timeout aborts the foreground waterfall's private signal and returns pending without cancelling the Turn. Gateway and `api-remotes` contain no question-specific timing policy.
|
||||
|
||||
The `userQuestions` Session projection folds existing events into the answerable question set and the settled one; no wait state, focus state, or deadline is logged. Each `request/header` records the exact tool schemas the model saw, and the fold tracks a call only while that header declares the timed `ask_user_question` schema, the one with a `timeout` parameter; the blocking legacy tool never declares it, so a legacy Session folds to the empty view and nothing about its rows, cards, or restart changes. A `tool/call` for `ask_user_question` under the timed schema opens a question. A `tool/result` marks it `continued` only when it carries the pending payload or is the synthetic `TOOL_OUTCOME_UNKNOWN` result Session resume repair appends; an answer batch moves it into `settled` with that batch, and any failure drops it. A late reply moves a continued question into `settled` the moment its message enters the agent inbox, together with the answer batch read out of that message: the timed call's own result recorded the timeout, so a transcript row has nowhere else to read what the user finally chose. A reply whose text carries no readable batch settles the call with no answers, and one naming a call that is not active changes nothing, so the inbox splice and the durable user message record the same reply once.
|
||||
|
||||
```ts type-equiv
|
||||
/** `open` while the tool call may still return the answer; `continued` once the answer can only arrive as a new turn. */
|
||||
type UserQuestionState = 'open' | 'continued'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** One unanswered timed `ask_user_question` call reconstructed from the Session log. */
|
||||
interface PendingUserQuestion {
|
||||
readonly callId: ToolCallId
|
||||
readonly questions: readonly AskUserQuestionItem[]
|
||||
readonly state: UserQuestionState
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One timed `ask_user_question` call and the answers it settled with: the
|
||||
* batch its own result carried when the user answered inside the window,
|
||||
* otherwise the batch its late reply carried, because that call's own result
|
||||
* recorded the timeout. A transcript row reads what the user finally answered
|
||||
* from here, and only a call listed here was a timed one.
|
||||
*/
|
||||
interface SettledUserQuestion {
|
||||
/** The settled call. */
|
||||
readonly callId: ToolCallId
|
||||
/** The recorded batch, one entry per question; empty when the late reply carried none. */
|
||||
readonly answers: readonly AskUserQuestionAnswerItem[]
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Both halves of one Session's timed `ask_user_question` state, as every
|
||||
* Client reads them. A call made while the blocking legacy tool was in
|
||||
* effect appears in neither half.
|
||||
*/
|
||||
interface UserQuestionProjectionView {
|
||||
/** Calls that can still take an answer, in ask order. */
|
||||
readonly active: readonly PendingUserQuestion[]
|
||||
/** Calls an answer settled, in settlement order. */
|
||||
readonly settled: readonly SettledUserQuestion[]
|
||||
}
|
||||
```
|
||||
|
||||
While a question is open, the only answer path is the waterfall. Once it is `continued`, the `answer` Remote method steers a user message into the owning agent, waking an idle one; its source is `user-question-reply` with `outcome: 'answered'` and its text is the `answer_to_pending_question` payload. That method refuses an open question, and rejects with `BAD_ANSWER` a batch that does not name each question of the call exactly once. No Remote method abandons a question: a Client that puts its question surface away sends nothing, so a question left unanswered ends only through its timeout. The `user-question-reply` source also admits `outcome: 'dismissed'`, which the projection and the Client conversation node still read for Sessions that recorded one; no current caller produces it. When the Session was reopened after a Host restart, the Remote layer resumes the root agent first, and the reply enters as a new turn.
|
||||
|
||||
## Errors
|
||||
|
||||
`UserQuestionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `ASK_ABORTED`, or UI-side cancellation.
|
||||
`UserQuestionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `ASK_ABORTED`, or UI-side cancellation.
|
||||
|
||||
```ts type-equiv
|
||||
/** Stable error taxonomy for user-questions failures. */
|
||||
@@ -215,42 +132,6 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
`ctx.userQuestions`: validation plus the scoped answerer waterfall.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Answer a continued question. The reply is steered into the agent as a
|
||||
* user message whose source names the call; that message is also the
|
||||
* record that closes the question in the projection.
|
||||
* @param agent - Live root agent for the owning Session.
|
||||
* @param callId - Continued question identity.
|
||||
* @param answer - Complete structured answer batch, one item per question of the call.
|
||||
* @returns Whether the question was continued and accepted the answer.
|
||||
* @throws {UserQuestionError} `BAD_ANSWER` when the batch does not name each
|
||||
* question of the call exactly once.
|
||||
*/
|
||||
@Remote answer(agent: Agent, callId: ToolCallId, answer: AskUserQuestionAnswer): boolean
|
||||
|
||||
/**
|
||||
* Let one answer UI hold a live timed wait. Closing the stream releases its claim.
|
||||
* @param agent - Live root agent owning the question.
|
||||
* @param callId - Foreground tool call to attach to.
|
||||
* @param signal - Remote stream cancellation, including Client disconnect.
|
||||
* @returns One Host-computed remaining duration, or no frames once the wait ended.
|
||||
*/
|
||||
@Remote({ mode: 'stream' }) async *attachWait(agent: Agent, callId: ToolCallId, signal: AbortSignal): AsyncIterable<{ remainingMs: number }>
|
||||
|
||||
/**
|
||||
* Foreground wait whose first settlement the Client decides: the Client
|
||||
* rejects with `ASK_TIMED_OUT` when its countdown ends, and this method maps
|
||||
* that code to the pending result.
|
||||
* @param request - Questions, live owner agent, and abort signal.
|
||||
* @param callId - Tool call identity the Client card is keyed by.
|
||||
* @param timeoutMs - Positive foreground wait in milliseconds.
|
||||
* @returns The answer when it arrives inside the window, otherwise a pending
|
||||
* result, also when no connected Client claimed the request by the deadline.
|
||||
* @throws {UserQuestionError} `BAD_TIMEOUT` for a non-integer, non-positive,
|
||||
* or oversized wait.
|
||||
*/
|
||||
async askTimed( request: AskUserQuestionRequest & { agent: Agent }, callId: ToolCallId, timeoutMs: number, ): Promise<TimedUserQuestionResult>
|
||||
|
||||
/**
|
||||
* Ask the scoped answerer waterfall and wait for the user's answer.
|
||||
*
|
||||
@@ -270,8 +151,6 @@ async askTimed( request: AskUserQuestionRequest & { agent: Agent }, callId: Tool
|
||||
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
|
||||
```
|
||||
|
||||
Types: [Agent](core.md) · [ToolCallId](core.md)
|
||||
|
||||
Source: [`packages/interaction/user-questions/src/index.ts`](../../packages/interaction/user-questions/src/index.ts)
|
||||
|
||||
<a id="user-questions-events"></a>
|
||||
|
||||
@@ -22,7 +22,7 @@ interface AskUserQuestionOption {
|
||||
|
||||
## 呈现意图
|
||||
|
||||
`AskUserQuestionIntent` 可选地声明一种已知的决策类型。它以 `kind` 作为标记,以便后续加入更多意图;不认识某个标记的 UI 会渲染通用选项列表。意图只改变呈现——遵循它的 UI 回答的选项标签与通用 UI 相同,因此调用方读到的答案字段一致。`approve` 指明表示同意的选项,而不依赖选项顺序。`ask()` 拒绝两种类型无法表达的断言:`approve` 未命名该问题自己的任何选项,以及没有 `detail` 的问题声明了意图。
|
||||
`AskUserQuestionIntent` 可选地声明一种已知的决策类型。它按 `kind` 打标签,因此可以增加新的意图;不认识某个标签的 UI 渲染通用选项列表。意图只改变呈现方式——遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,因此调用方两种情况下读到的回答字段相同。`approve` 指名肯定选项,而不依赖选项顺序。`ask()` 会拒绝两种无法由类型系统表达的情况:`approve` 未指向该问题自身的任何选项,以及为没有 `detail` 的问题指定意图。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -103,92 +103,9 @@ interface AskUserQuestionAnswer {
|
||||
}
|
||||
```
|
||||
|
||||
计时回答器收到 `wait: { callId, timed: true }`。调用 id 标识卡片及前台等待。Client 调用 `attachWait` 接手等待并取得剩余时长;传输层转发原事件,不检查或改写这些字段。不带 `timed` 的 `wait` 表示无限期等待的具名卡片请求;没有 `wait` 则表示不带卡片 key 的阻塞请求。
|
||||
|
||||
```ts type-equiv
|
||||
/** Client-safe payload declared for the user-question answerer waterfall. */
|
||||
interface AskUserQuestionRequestEvent {
|
||||
/** Questions to display. */
|
||||
questions: AskUserQuestionItem[]
|
||||
/** Agent identity projected to the corresponding Client Context in transit. */
|
||||
agent?: Agent
|
||||
/** Cancellation lifetime of the pending request. */
|
||||
signal?: AbortSignal
|
||||
/**
|
||||
* Tool call the Client card is keyed by. Timed answerers attach to the
|
||||
* business wait stream before starting their local countdown.
|
||||
*/
|
||||
wait?: {
|
||||
/** Tool call the Client card is keyed by. */
|
||||
callId: ToolCallId
|
||||
/** True for a foreground wait that requires a Client claim; absent for indefinite waits. */
|
||||
timed?: boolean
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 计时问题与迟到回复
|
||||
|
||||
计时问题在有界的前台窗口内等待,然后让 agent 继续独立工作。用户在窗口内回答,工具返回整批答案;否则 `TimedUserQuestionResult` 为 `{ pending: true, callId }`。pending 表示问题仍可回答,不是空答案、拒绝、确认或授权。
|
||||
|
||||
```ts type-equiv
|
||||
/** Timed ask result returned when the foreground answer window closes. */
|
||||
type TimedUserQuestionResult = AskUserQuestionAnswer | { pending: true; callId: ToolCallId }
|
||||
```
|
||||
|
||||
Client 接手通过业务 Remote stream 表达,不等同于 Gateway 投递。`TimedQuestionWait` 保存原 Host deadline,仅在没有接手记录时运行计时器。`attachWait` stream 发出当前剩余时长,Client 用本地时钟倒计时。聚焦、编辑和「慢慢回答」状态仍归本地所有。stream 取消释放相应接手记录;最后一个接手方离开后,Host 恢复原 deadline。无人接手时超时中止前台 waterfall 专属的 signal 并返回 pending,不取消 Turn。Gateway 与 `api-remotes` 不包含问答专属计时策略。
|
||||
|
||||
`userQuestions` Session 投影把现有事件折叠成可回答的问题集合与已结算的集合;不记录任何等待状态、聚焦状态或 deadline。每个 `request/header` 都记录了模型看到的确切工具 schema,折叠只在该请求头声明了 timed `ask_user_question` schema(带 `timeout` 参数的那一个)时才跟踪调用;阻塞式 legacy 工具从不声明它,因此 legacy 会话折叠为空视图,其行、卡片和重启行为都不会改变。timed schema 下 `ask_user_question` 的 `tool/call` 打开一个问题。`tool/result` 只在两种情况下把它标为 `continued`:带 pending 载荷,或是 Session resume 修复补写的 `TOOL_OUTCOME_UNKNOWN` 合成结果;回答批次把它连同该批次移入 `settled`,任何失败都丢弃它。迟到回复在其消息进入 agent inbox 的那一刻把已继续的问题移入 `settled`,连同从该消息读出的回答批次:计时调用自己的结果记录的是超时,transcript 行无处可读用户最终的选择。回复正文不含可读批次时,该调用以零答案结算;回复指向的调用已不在可回答集合中时不改变任何内容,因此 inbox splice 与持久的用户消息只记录同一条回复一次。
|
||||
|
||||
```ts type-equiv
|
||||
/** `open` while the tool call may still return the answer; `continued` once the answer can only arrive as a new turn. */
|
||||
type UserQuestionState = 'open' | 'continued'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** One unanswered timed `ask_user_question` call reconstructed from the Session log. */
|
||||
interface PendingUserQuestion {
|
||||
readonly callId: ToolCallId
|
||||
readonly questions: readonly AskUserQuestionItem[]
|
||||
readonly state: UserQuestionState
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One timed `ask_user_question` call and the answers it settled with: the
|
||||
* batch its own result carried when the user answered inside the window,
|
||||
* otherwise the batch its late reply carried, because that call's own result
|
||||
* recorded the timeout. A transcript row reads what the user finally answered
|
||||
* from here, and only a call listed here was a timed one.
|
||||
*/
|
||||
interface SettledUserQuestion {
|
||||
/** The settled call. */
|
||||
readonly callId: ToolCallId
|
||||
/** The recorded batch, one entry per question; empty when the late reply carried none. */
|
||||
readonly answers: readonly AskUserQuestionAnswerItem[]
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Both halves of one Session's timed `ask_user_question` state, as every
|
||||
* Client reads them. A call made while the blocking legacy tool was in
|
||||
* effect appears in neither half.
|
||||
*/
|
||||
interface UserQuestionProjectionView {
|
||||
/** Calls that can still take an answer, in ask order. */
|
||||
readonly active: readonly PendingUserQuestion[]
|
||||
/** Calls an answer settled, in settlement order. */
|
||||
readonly settled: readonly SettledUserQuestion[]
|
||||
}
|
||||
```
|
||||
|
||||
问题开放期间唯一的回答路径是 waterfall。变为 `continued` 之后,`answer` Remote 方法向所属 agent steer 一条用户消息并叫醒空闲的 agent;其 source 为带 `outcome: 'answered'` 的 `user-question-reply`,正文是 `answer_to_pending_question` 载荷。该方法拒绝开放中的问题,并以 `BAD_ANSWER` 拒绝没有恰好各命名该调用每个问题一次的批次。没有任何 Remote 方法会放弃问题:Client 收起提问界面时不发送任何内容,因此未被回答的问题只能通过超时结束。`user-question-reply` 来源也接受 `outcome: 'dismissed'`,projection 与 Client 对话节点仍会为记录过它的会话读取;当前没有调用方产生它。Session 在 Host 重启后被重开时,Remote 层先恢复根 agent,回复作为新一轮进入。
|
||||
|
||||
## 错误
|
||||
|
||||
`UserQuestionError` 继承 `HarnessError`,因此 `ctx.tools.execute()` 会保留 `{ name, code }`,用于面向模型的工具失败,如 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`ASK_ABORTED` 或 UI 侧取消。
|
||||
`UserQuestionError` 继承 `HarnessError`,因此 `ctx.tools.execute()` 会保留 `{ name, code }`,用于面向模型的工具失败,如 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`ASK_ABORTED` 或 UI 侧取消。
|
||||
|
||||
```ts type-equiv
|
||||
/** Stable error taxonomy for user-questions failures. */
|
||||
@@ -215,42 +132,6 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
`ctx.userQuestions`: validation plus the scoped answerer waterfall.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Answer a continued question. The reply is steered into the agent as a
|
||||
* user message whose source names the call; that message is also the
|
||||
* record that closes the question in the projection.
|
||||
* @param agent - Live root agent for the owning Session.
|
||||
* @param callId - Continued question identity.
|
||||
* @param answer - Complete structured answer batch, one item per question of the call.
|
||||
* @returns Whether the question was continued and accepted the answer.
|
||||
* @throws {UserQuestionError} `BAD_ANSWER` when the batch does not name each
|
||||
* question of the call exactly once.
|
||||
*/
|
||||
@Remote answer(agent: Agent, callId: ToolCallId, answer: AskUserQuestionAnswer): boolean
|
||||
|
||||
/**
|
||||
* Let one answer UI hold a live timed wait. Closing the stream releases its claim.
|
||||
* @param agent - Live root agent owning the question.
|
||||
* @param callId - Foreground tool call to attach to.
|
||||
* @param signal - Remote stream cancellation, including Client disconnect.
|
||||
* @returns One Host-computed remaining duration, or no frames once the wait ended.
|
||||
*/
|
||||
@Remote({ mode: 'stream' }) async *attachWait(agent: Agent, callId: ToolCallId, signal: AbortSignal): AsyncIterable<{ remainingMs: number }>
|
||||
|
||||
/**
|
||||
* Foreground wait whose first settlement the Client decides: the Client
|
||||
* rejects with `ASK_TIMED_OUT` when its countdown ends, and this method maps
|
||||
* that code to the pending result.
|
||||
* @param request - Questions, live owner agent, and abort signal.
|
||||
* @param callId - Tool call identity the Client card is keyed by.
|
||||
* @param timeoutMs - Positive foreground wait in milliseconds.
|
||||
* @returns The answer when it arrives inside the window, otherwise a pending
|
||||
* result, also when no connected Client claimed the request by the deadline.
|
||||
* @throws {UserQuestionError} `BAD_TIMEOUT` for a non-integer, non-positive,
|
||||
* or oversized wait.
|
||||
*/
|
||||
async askTimed( request: AskUserQuestionRequest & { agent: Agent }, callId: ToolCallId, timeoutMs: number, ): Promise<TimedUserQuestionResult>
|
||||
|
||||
/**
|
||||
* Ask the scoped answerer waterfall and wait for the user's answer.
|
||||
*
|
||||
@@ -270,8 +151,6 @@ async askTimed( request: AskUserQuestionRequest & { agent: Agent }, callId: Tool
|
||||
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
|
||||
```
|
||||
|
||||
Types: [Agent](core.zh.md) · [ToolCallId](core.zh.md)
|
||||
|
||||
Source: [`packages/interaction/user-questions/src/index.ts`](../../packages/interaction/user-questions/src/index.ts)
|
||||
|
||||
<a id="user-questions-events"></a>
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 6cc2cd9ced70bb09
|
||||
zh: 1c0e6cb4ba2ed637
|
||||
/tool-schema-catalog/tool-package-map:
|
||||
en: 167287d095eee2f5
|
||||
zh: 18720d5e81379d2f
|
||||
en: 72e2a375567f5850
|
||||
zh: b51b8ea5b52814fd
|
||||
/tool-schema-catalog/deepseek-ai-dsh-plugin-manager:
|
||||
en: e0143916bb634762
|
||||
zh: e0143916bb634762
|
||||
@@ -54,8 +54,8 @@
|
||||
en: e713a4a11a15d13c
|
||||
zh: e713a4a11a15d13c
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-ask-user/ask-user-question:
|
||||
en: 055268ba4dcd32f9
|
||||
zh: a1a3c4bfcbf698ed
|
||||
en: f42b7f17ad59577a
|
||||
zh: 7e1eee22ff35d4b1
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tools:
|
||||
en: 76fbbf0fe38aef10
|
||||
zh: 76fbbf0fe38aef10
|
||||
|
||||
@@ -18,7 +18,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-plugin-manager` | `plugin_manager` | `ctx.tools`, `ctx.pluginManager`, `ctx.sandboxPolicy` | `tool/call`, `tool/result`, `user/message` | - | - |
|
||||
| `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - |
|
||||
| `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after an answer or timeout`, `late user/message` | - | ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely. |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
|
||||
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.ptcRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
|
||||
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
|
||||
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. With a job registry composed every call registers with the generic `ctx.jobs` runtime as it starts, collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; without one, or with `enableRunInBackground: false`, the tool registers a foreground-only schema without the `run_in_background` parameter. |
|
||||
@@ -515,7 +515,7 @@ Ask the user a concise question when you need confirmation, a choice, or missing
|
||||
|
||||
Source: [`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)
|
||||
|
||||
ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely.
|
||||
ask_user_question pauses the tool call until the active UI provider returns a human answer.
|
||||
|
||||
<a id="deepseek-aidsh-tools"></a>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
| `@deepseek-ai/dsh-plugin-manager` | `plugin_manager` | `ctx.tools`, `ctx.pluginManager`, `ctx.sandboxPolicy` | `tool/call`, `tool/result`, `user/message` | - | - |
|
||||
| `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - |
|
||||
| `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`、`stagehand_extract`、`stagehand_navigate`、`stagehand_observe`、`stagehand_screenshot`、`stagehand_tabs` | `ctx.browserUse`、`ctx.agents`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | - |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after an answer or timeout`、`late user/message` | - | ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。 |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after a UI/provider answers the question` | - | ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类答案。 |
|
||||
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.ptcRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 |
|
||||
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 |
|
||||
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job 注册表时,每次调用一启动就注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;没有注册表或 `enableRunInBackground: false` 时,工具注册不带 `run_in_background` 参数的纯前台 schema。 |
|
||||
@@ -519,7 +519,7 @@
|
||||
|
||||
来源:[`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)
|
||||
|
||||
ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。
|
||||
ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类答案。
|
||||
|
||||
<a id="deepseek-aidsh-tools"></a>
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import agentPresetsRemote from '@deepseek-ai/dsh-agent-preset-registry/remote'
|
||||
import userQuestionsRemote from '@deepseek-ai/dsh-user-questions/remote'
|
||||
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
|
||||
import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote'
|
||||
import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote'
|
||||
@@ -38,7 +37,6 @@ export type {} from '@deepseek-ai/dsh-plugin-manager/remote'
|
||||
export type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/remote'
|
||||
export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
|
||||
export type {} from '@deepseek-ai/dsh-agent-preset-registry/remote'
|
||||
export type {} from '@deepseek-ai/dsh-user-questions/remote'
|
||||
export type {} from '@deepseek-ai/dsh-commands/remote'
|
||||
export type {} from '@deepseek-ai/dsh-api-settings-controller/remote'
|
||||
export type {} from '@deepseek-ai/dsh-api-account-controller/remote'
|
||||
@@ -181,7 +179,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
pluginInventoryRemote, pluginManagerRemote, pluginRegistryProbeRemote, messageFeedbackRemote, sessionFeedbackRemote,
|
||||
fileUploadsRemote, sessionReferencesRemote,
|
||||
permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
|
||||
officeToPdfRemote, userQuestionsRemote,
|
||||
officeToPdfRemote,
|
||||
]) {
|
||||
disposers.push(await ctx.remote.$mount(contribution))
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: a6d29a5ea3fd2362
|
||||
zh: dbd07bf82cdd61b1
|
||||
/chat-conversation-node-rules/process-grouping:
|
||||
en: c2650489ded57503
|
||||
zh: f2554d74e75ae0f9
|
||||
en: 276a97b95610f5e8
|
||||
zh: 7627b2afb0a7c0f4
|
||||
/chat-conversation-node-rules/process-grouping/examples:
|
||||
en: 2a550f7e04bce4b0
|
||||
zh: b8f95b687448cd77
|
||||
|
||||
@@ -172,8 +172,6 @@ Switching modes retains manually opened groups and inner disclosures. It changes
|
||||
|
||||
A group collects adjacent process content within one Turn. Step-number changes alone do not split it. All four modes use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed.
|
||||
|
||||
When `question-reply` and `turn-trigger` project the same message id, grouping references only the question reply. Both Nodes stay in the Node Store; the duplicate trigger neither renders nor splits a group. The Turn control precedes the group containing an opening reply. This aggregation also applies before the owning Turn is loaded.
|
||||
|
||||
| Input | Membership and segmentation |
|
||||
|---|---|
|
||||
| Non-blank Assistant reasoning | Append one `reasoning` reference for that Assistant to the current group, creating a group if necessary. |
|
||||
|
||||
@@ -172,8 +172,6 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
|
||||
|
||||
一组收集同一轮次内相邻的过程内容,仅 Step 编号变化不会拆组。四档模式使用同一分组结果。组的 `closed` 表示这段内容已经结束,不表示 UI 开合控件处于收起状态。
|
||||
|
||||
当 `question-reply` 和 `turn-trigger` 投影自同一个消息 id 时,分组只引用问答回复。两个 Node 都保留在 Node Store 中;重复的触发节点既不显示,也不切开分组。整轮控件位于包含开场回复的分组之前。所属轮次尚未加载时也执行这一聚合。
|
||||
|
||||
| 输入 | 归属与切分 |
|
||||
|---|---|
|
||||
| 非空白 Assistant 推理 | 将该 Assistant 的一个 `reasoning` 引用加入当前组,必要时创建组。 |
|
||||
|
||||
@@ -53,15 +53,6 @@ function readNode(input: ProcessInput, key: NodeKey): ChatNode {
|
||||
return node
|
||||
}
|
||||
|
||||
function questionReplyIds(input: ProcessInput, keys: readonly NodeKey[]): ReadonlySet<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const key of keys) {
|
||||
const node: ChatConversationViewNode = readNode(input, key)
|
||||
if (node.kind === 'question-reply') ids.add(node.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/** One group's members and cached summary, refreshed together when its content changes. */
|
||||
class ProcessGroup {
|
||||
private nodes: readonly ChatNode[] = []
|
||||
@@ -152,22 +143,17 @@ class TurnGroups {
|
||||
}
|
||||
let previous: NodeKey | undefined
|
||||
let followed = false
|
||||
const keys = input.readTurn(this.turn)
|
||||
const replies = questionReplyIds(input, keys)
|
||||
for (const key of keys) {
|
||||
for (const key of input.readTurn(this.turn)) {
|
||||
const position = readPosition(input, key)
|
||||
if (previous !== undefined && position.previous !== previous) flush(true)
|
||||
previous = key
|
||||
followed = position.next !== undefined
|
||||
const node = readNode(input, key)
|
||||
// Both Definitions retain the same message id; only its question presentation renders.
|
||||
if (node.kind === 'turn-trigger' && replies.has(node.id)) continue
|
||||
if (INDEPENDENT.has(node.kind)) {
|
||||
flush(true)
|
||||
emit(key, { kind: 'node', key })
|
||||
} else if (node.kind === 'turn-process') {
|
||||
// A coalesced opening reply can sort before the control that folds its group.
|
||||
emit(pending[0]?.key ?? key, { kind: 'node', key })
|
||||
emit(key, { kind: 'node', key })
|
||||
} else if (node.kind === 'assistant-step') {
|
||||
if (reasoning(node)) pending.push({ kind: 'node', key, groupPart: 'reasoning' })
|
||||
if (reply(node)) {
|
||||
@@ -291,14 +277,9 @@ export class ProcessState {
|
||||
}
|
||||
|
||||
private rootEntries(input: ProcessInput): RenderEntry[] {
|
||||
const unscoped = input.order.filter(key => readPosition(input, key).turn === undefined)
|
||||
const replies = questionReplyIds(input, unscoped)
|
||||
return input.order.flatMap<RenderEntry>((key) => {
|
||||
const turn = readPosition(input, key).turn
|
||||
if (turn === undefined) {
|
||||
const node = readNode(input, key)
|
||||
return node.kind === 'turn-trigger' && replies.has(node.id) ? [] : [{ kind: 'node', key }]
|
||||
}
|
||||
if (turn === undefined) return [{ kind: 'node', key }]
|
||||
const groups = this.turns.get(turn)
|
||||
if (groups === undefined) throw new Error(`Chat grouping order is missing Turn ${turn}`)
|
||||
return groups.references(key)
|
||||
|
||||
@@ -2510,27 +2510,6 @@ describe('ChatView', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('folds a question reply with the surrounding process and reveals it on expansion', () => {
|
||||
const base = chatSnapshotFixture({
|
||||
nodes: [user(1, 'question'), assistant(2, 'inspect', 1, 1), assistant(4, 'final answer', 1, 2)],
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]),
|
||||
})
|
||||
const snapshot = new ChatSnapshotBuilder().replace({
|
||||
nodes: [...base.nodes.values(), {
|
||||
key: 'reply', id: 'reply', kind: 'question-reply', target: 'chat', anchorSeq: 3,
|
||||
location: { kind: 'turn', turn: base.timeline.turns.get(1)! }, visibility: 'visible', data: {},
|
||||
}],
|
||||
timeline: base.timeline,
|
||||
})
|
||||
const h = makeHarness({}, {}, snapshot)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const reply = view.container.querySelector('[data-chat-flow-kind="question-reply"]')!
|
||||
expect(reply.getAttribute('hidden')).toBe('until-found')
|
||||
fireEvent.click(turnProcessControl(view.container)!)
|
||||
expect(reply.getAttribute('hidden')).toBeNull()
|
||||
})
|
||||
|
||||
it('folds final-step reasoning under the fallback title when every summary count is zero', () => {
|
||||
const final = {
|
||||
...assistant(3, 'final answer', 1, 1),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ConversationTimelineSnapshot, TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatConversationViewNode, ChatNode } from '../src/client/contract/chat-nodes.ts'
|
||||
import type { ChatNode } from '../src/client/contract/chat-nodes.ts'
|
||||
import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
|
||||
import { ProcessState } from '../src/client/conversation-nodes/process-groups.ts'
|
||||
import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts'
|
||||
@@ -48,19 +48,7 @@ function separator(seq: number, inTurn: boolean): ChatNode<'user'> | ChatNode<'s
|
||||
return inTurn ? { ...user, kind: 'steering', data: { ...user.data, kind: 'steering', messageId: 'steer' as MessageId } } : user
|
||||
}
|
||||
|
||||
function replyNodes(inTurn: boolean): readonly [ChatConversationViewNode, ChatConversationViewNode] {
|
||||
const trigger: ChatConversationViewNode = {
|
||||
key: 'generic-reply', id: 'reply-message', kind: 'turn-trigger', target: 'chat', anchorSeq: 6,
|
||||
location: inTurn ? { kind: 'turn', turn } : { kind: 'session' }, visibility: 'visible',
|
||||
data: {
|
||||
kind: 'context', seq: 6, time: 6, content: [{ type: 'text', text: 'answer' }],
|
||||
source: { kind: 'user-question-reply' }, producer: { role: 'inject', label: null }, form: null,
|
||||
},
|
||||
}
|
||||
return [trigger, { ...trigger, key: 'question-reply', kind: 'question-reply', data: {} }]
|
||||
}
|
||||
|
||||
function harness(nodes: readonly ChatConversationViewNode[], currentTimeline = timeline) {
|
||||
function harness(nodes: ChatNode[], currentTimeline = timeline) {
|
||||
const builder = new ChatSnapshotBuilder()
|
||||
const state = new ProcessState()
|
||||
const store = new ConversationGroupStore<ProcessGroupData>()
|
||||
@@ -129,71 +117,6 @@ describe('Definition-owned Chat process groups', () => {
|
||||
expect(source.getSnapshot()?.data.summary).toEqual({ counts: [{ kind, count: 1 }], running: undefined, runningDetail: '' })
|
||||
})
|
||||
|
||||
it.each(['a-trigger', 'z-trigger'])('renders one question reply without splitting its group for duplicate %s', (key) => {
|
||||
const [generic, reply] = replyNodes(true)
|
||||
const trigger = { ...generic, key }
|
||||
const h = harness([tool('before', 4), trigger, reply, tool('after', 8)])
|
||||
expect(h.store.entries.map(entry => entry.kind)).toEqual(['group'])
|
||||
const group = h.store.entries[0]!
|
||||
if (group.kind !== 'group') throw new Error('expected group')
|
||||
expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key))
|
||||
.toEqual(['before', reply.key, 'after'])
|
||||
expect(h.snapshot.nodes.get(trigger.key)).toBeDefined()
|
||||
expect(h.snapshot.nodes.get(reply.key)).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the Turn control before the group containing its coalesced opening reply', () => {
|
||||
const control: ChatNode<'turn-process'> = {
|
||||
key: 'control', id: '1', kind: 'turn-process', target: 'chat', anchorSeq: 0.9,
|
||||
location: { kind: 'turn', turn }, visibility: 'visible',
|
||||
data: {
|
||||
turn: 1, controlAnchorSeq: 1, processStartSeq: 1, answerAnchorSeq: null,
|
||||
answerStep: null, inlineReasoning: false, messageCount: 0, toolCallCount: 1, subagentCount: 0,
|
||||
},
|
||||
}
|
||||
const h = harness([control, ...replyNodes(true), tool('after', 8)])
|
||||
expect(h.store.entries.map(entry => entry.kind)).toEqual(['node', 'group'])
|
||||
expect(h.store.entries[0]).toEqual({ kind: 'node', key: control.key })
|
||||
})
|
||||
|
||||
it('aggregates a newly projected reply with its existing trigger', () => {
|
||||
const [trigger, reply] = replyNodes(true)
|
||||
const h = harness([tool('before', 4), trigger, tool('after', 8)])
|
||||
h.builder.apply({ upserts: [reply], timeline })
|
||||
h.commit()
|
||||
expect(h.store.entries.map(entry => entry.kind)).toEqual(['group'])
|
||||
const group = h.store.entries[0]!
|
||||
if (group.kind !== 'group') throw new Error('expected group')
|
||||
expect(h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key))
|
||||
.toEqual(['before', reply.key, 'after'])
|
||||
|
||||
h.builder.apply({ upserts: [{ ...reply, visibility: 'hidden' }], timeline })
|
||||
h.commit()
|
||||
expect(h.store.entries.map(entry => entry.kind)).toEqual(['group', 'node', 'group'])
|
||||
expect(h.store.entries[1]).toEqual({ kind: 'node', key: trigger.key })
|
||||
})
|
||||
|
||||
it('aggregates reply projections before their Turn has been loaded', () => {
|
||||
const [trigger, reply] = replyNodes(false)
|
||||
const h = harness([trigger, reply])
|
||||
expect(h.store.entries).toEqual([{ kind: 'node', key: reply.key }])
|
||||
expect(h.snapshot.nodes.get(trigger.key)).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps unscoped aggregation current when a reply arrives', () => {
|
||||
const [trigger, reply] = replyNodes(false)
|
||||
const h = harness([trigger])
|
||||
h.builder.apply({ upserts: [reply], timeline })
|
||||
h.commit()
|
||||
expect(h.store.entries).toEqual([{ kind: 'node', key: reply.key }])
|
||||
})
|
||||
|
||||
it.each([true, false])('does not merge identical text from different messages, scoped: %s', (inTurn) => {
|
||||
const [trigger, reply] = replyNodes(inTurn)
|
||||
const h = harness([trigger, { ...reply, id: 'different-message', data: trigger.data }])
|
||||
expect(h.store.entries).toContainEqual({ kind: 'node', key: trigger.key })
|
||||
})
|
||||
|
||||
it('retains a group through repeated prepends and a later append', () => {
|
||||
const b = tool('b', 4)
|
||||
const c = tool('c', 6)
|
||||
|
||||
@@ -18,7 +18,7 @@ function viewNode(kind: string, seq: number, data: Record<string, unknown>): Cha
|
||||
location: { kind: 'session' },
|
||||
visibility: 'visible',
|
||||
data: { kind, seq, time: seq, ...data },
|
||||
}
|
||||
} as unknown as ChatConversationViewNode
|
||||
}
|
||||
const user = (seq: number, text: string) =>
|
||||
viewNode('user', seq, { content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
|
||||
@@ -289,12 +289,6 @@ export const zh = {
|
||||
'command.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件',
|
||||
'ask.rowTitle': '提问',
|
||||
'ask.waiting': '等待回答',
|
||||
'ask.pending': '已继续工作,仍可回答',
|
||||
'ask.pendingDetail': '等待中的问题仍可在输入框中回答',
|
||||
'ask.reopen': '回答',
|
||||
'ask.review': '查看回答',
|
||||
'ask.closed': '已结束',
|
||||
'ask.closedDetail': '该问题已结束,结果见下方对话',
|
||||
'ask.cancelled': '已取消',
|
||||
'ask.cancelledDetail': '本轮已取消,未提交回答',
|
||||
'ask.interrupted': '已中断',
|
||||
@@ -665,12 +659,6 @@ export const en = {
|
||||
'command.attachmentsUnsupported': '/{command} does not accept attachments; remove them first',
|
||||
'ask.rowTitle': 'Ask question',
|
||||
'ask.waiting': 'waiting',
|
||||
'ask.pending': 'continued; answer still available',
|
||||
'ask.pendingDetail': 'These pending questions remain answerable from the composer.',
|
||||
'ask.reopen': 'Answer',
|
||||
'ask.review': 'View answers',
|
||||
'ask.closed': 'closed',
|
||||
'ask.closedDetail': 'This question is closed; its outcome is in the conversation below.',
|
||||
'ask.cancelled': 'cancelled',
|
||||
'ask.cancelledDetail': 'This question set was cancelled before answers were submitted.',
|
||||
'ask.interrupted': 'interrupted',
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 5332d87e76037f07
|
||||
zh: db5d06e7e46b8429
|
||||
/deepseek-ai-dsh-client-ui-tool/use-this-package/built-in-views:
|
||||
en: c2c45961f9f3e648
|
||||
zh: 2432ca99badeb3ba
|
||||
en: 4b69e36952574494
|
||||
zh: 1f9842b40931adb6
|
||||
/deepseek-ai-dsh-client-ui-tool/understand-the-implementation:
|
||||
en: 00bc7b0c84edc603
|
||||
zh: 1cfcf5b435248a91
|
||||
@@ -30,14 +30,11 @@
|
||||
en: 2959b10b01e08a7b
|
||||
zh: 7084ca22650cbb5b
|
||||
/deepseek-ai-dsh-client-ui-tool/understand-the-implementation/cards:
|
||||
en: cf86eff1c84c1107
|
||||
zh: f88da20257eb31d4
|
||||
/deepseek-ai-dsh-client-ui-tool/understand-the-implementation/declared-optional-capabilities:
|
||||
en: 296cd1f16bf23ecd
|
||||
zh: 6b0584d44c83a2b8
|
||||
en: 401a0118de2dba33
|
||||
zh: 9efe590dd485c336
|
||||
/deepseek-ai-dsh-client-ui-tool/further-exploration:
|
||||
en: b37600769951cbca
|
||||
zh: 816589549d21d176
|
||||
en: 886059e6bd594ebf
|
||||
zh: 94cfe8d0a789f413
|
||||
/deepseek-ai-dsh-client-ui-tool/model-experience:
|
||||
en: 1e5e72dd269855d5
|
||||
zh: 81a4756d460e3af9
|
||||
|
||||
@@ -49,9 +49,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` dis
|
||||
|
||||
Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). Shared rows use the same `ToolRow` in all stages. Their row model selects the title and combines any generic tool-name prefix with the available argument summary independently of lifecycle state. Tool-owned titles omit the English name. The shared argument parser returns no call during preparation and does not parse partial JSON. Write/edit use separate preparing and dispatched components, so only the preparing component invokes `useToolCallArgumentsPartial`; start and result share the dispatched component. Custom renderers such as Bash, Skill, and Cordis handle preparation separately; their argument-dependent components accept `StartedToolCallViewProps`.
|
||||
|
||||
This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with answers by their stable ids and shows readable question/answer lines when expanded; the answers come from the result when it carried them, and from the `userQuestions` projection when a late reply settled a timed call whose result recorded only the timeout, so both read alike. A cancelled or interrupted row shows its verdict and original questions without inventing answers. A question row carries a pill beside its summary, labelled with the row's own question icon: while the `userQuestions` Session projection still lists the call as answerable, the pill reopens that question's live answer panel in the composer, and once the call settles with answers it opens the same panel read-only over those questions and answers. A timed call that closed with no answers reads as closed and offers no pill, and neither does a cancelled or interrupted row nor an answered call whose questions failed validation; a pill whose panel provider declines expands the row instead. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
|
||||
|
||||
Answer-panel buttons belong to the question renderer. It composes `DisclosureRow` with its own action and transcript; generic `ToolRow` does not select or dispatch question actions.
|
||||
This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
|
||||
|
||||
-----
|
||||
|
||||
@@ -86,11 +84,6 @@ Recorded tool details cover goal and schedule tools, Cordis inspection, workflow
|
||||
Expanded status dots and labels use static semantic colors. Receipt and job-output headers keep neutral text and omit the status while expanded. An interruption receipt confirms only that interruption was requested.
|
||||
|
||||
The terminal model uses `hasSpillNotice` from the browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry, not an independent UI pattern. The [spill-policy README](../../spill/spill-policy/README.md#shared-notice-ownership) owns notice formatting and recognition. This check conservatively selects generic output; matching text cannot authenticate its source, and replay leaves recorded result bytes untouched.
|
||||
|
||||
### Declared optional capabilities
|
||||
|
||||
This package declares `UserQuestionPanels` on the browser `Context` for its question row: `reveal(sessionId, callId): boolean` shows that call's answer panel and reports whether one was left to show, and `review(sessionId, callId, record): boolean` shows a settled call's recorded questions and answers as a read-only panel, with the record read from this row's own recorded call plus whichever recorded its answers — the result, or the projection's late-reply entry. The question view reads both through `ctx.get('userQuestionPanels')` inside its slot `inject`, so a composition without question UI renders the same row with no action. `ui-user-questions` provides the capability and owns the panel behavior behind it.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -103,7 +96,6 @@ These pages cover the conversation host, the view slots, and the card models.
|
||||
- [ui-conversation](../ui-conversation/README.md) — the chat surface dispatching `tool-call` nodes to this package.
|
||||
- [ui-primitives](../ui-primitives/README.md) — the output card atoms the built-in views compose.
|
||||
- [ui-skill](../ui-skill/README.md) — a business-owned registration for the `skill` tool.
|
||||
- [ui-user-questions](../ui-user-questions/README.md) — the question panels the `ask_user_question` row reopens.
|
||||
- [Auto review](../../experimental/auto-review/README.md) — the structured denial identity and user-visible reason owner.
|
||||
- [Conversation subsystem](../../../docs/subsystems/conversation.md) — how a business-owned feature registers a Conversation node.
|
||||
- [Slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) — the composition model behind the keyed slot.
|
||||
|
||||
@@ -49,9 +49,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字
|
||||
|
||||
每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`。行模型统一选择标题,并组合通用工具名前缀与已有参数摘要,不按生命周期阶段改变前缀;专用标题不附带英文名。准备阶段的共享参数解析入口直接返回无调用,不解析部分 JSON。write/edit 将准备态和派发后阶段拆成两个组件,只有准备态组件调用 `useToolCallArgumentsPartial`,start 与 result 共用派发后组件。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。
|
||||
|
||||
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与回答,展开后显示可读的问答行;回答在结果携带时取自结果,而计时调用的结果只记录了超时时,则取自 `userQuestions` 投影中迟到回复结算它时留下的答案,两者读起来一致。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。问题行在摘要旁带一个胶囊按钮,按钮上是该行自己的问题图标:只要 `userQuestions` 会话投影仍把该调用列为可回答,点击它就在编辑器位置重新打开这道问题的实时回答面板;调用带着回答结束后,它以只读方式打开同一个面板,展示这些问题与回答。计时调用在没有任何回答的情况下结束时读作已结束且不提供该按钮,已取消或已中断的行同样不提供,问题字段校验失败的已回答调用也不提供;面板能力拒绝时,点击改为展开该行。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
|
||||
|
||||
回答面板按钮归问题 renderer 所有。它使用 `DisclosureRow` 组合自己的操作与 transcript;通用 `ToolRow` 不选择或派发问题操作。
|
||||
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
|
||||
|
||||
-----
|
||||
|
||||
@@ -86,11 +84,6 @@ Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用
|
||||
展开后的状态圆点和文字使用静态语义色。操作回执和任务输出的标题保持中性色,展开时省略标题中的状态。中断回执仅确认已发出中断请求。
|
||||
|
||||
terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 的 `hasSpillNotice`,而非独立的 UI 匹配规则。[spill-policy README](../../spill/spill-policy/README.zh.md#shared-notice-ownership) 负责提示文本的格式化与识别。该检查保守地选择通用输出;匹配的文本无法证明其来源,回放也不改变已记录的结果字节。
|
||||
|
||||
### 声明的可选能力
|
||||
|
||||
本包在浏览器 `Context` 上为其问题行声明 `UserQuestionPanels`:`reveal(sessionId, callId): boolean` 显示该调用的回答面板,并报告是否还有面板可显示;`review(sessionId, callId, record): boolean` 把已结束调用记录下来的问题与回答显示为只读面板,记录由该行自己记录的调用,加上记录了回答的那一方——结果,或投影中迟到回复的条目——共同解析得出。问题视图在自己的 slot `inject` 中经 `ctx.get('userQuestionPanels')` 读取两者,因此不含提问 UI 的组装渲染同样的行,只是没有动作。该能力由 `ui-user-questions` 提供,其背后的面板行为也由它拥有。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -103,7 +96,6 @@ terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice
|
||||
- [ui-conversation](../ui-conversation/README.zh.md)——把 `tool-call` 节点分派给本包的聊天界面。
|
||||
- [ui-primitives](../ui-primitives/README.zh.md)——内置视图所拼装的输出卡片原子组件。
|
||||
- [ui-skill](../ui-skill/README.zh.md)——`skill` 工具的业务自有注册。
|
||||
- [ui-user-questions](../ui-user-questions/README.zh.md)——`ask_user_question` 行重新打开的提问面板。
|
||||
- [Auto review](../../experimental/auto-review/README.zh.md)——结构化拒绝身份与用户可见原因的 owner。
|
||||
- [Conversation 子系统](../../../docs/subsystems/conversation.zh.md)——业务自有功能如何注册 Conversation node。
|
||||
- [slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)——keyed slot 背后的组合模型。
|
||||
|
||||
@@ -75,8 +75,7 @@
|
||||
"@deepseek-ai/dsh-attachment": "workspace:*",
|
||||
"clsx": "^2.0.0",
|
||||
"@deepseek-ai/dsh-ptc-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:*"
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Tool UI slot declarations and their composed component props. */
|
||||
import type {
|
||||
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SessionIdOf, SlotHookFactory,
|
||||
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SlotHookFactory,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
ToolResultNode, UseDisclosure,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { MessageImageLoader, MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AskUserQuestionAnswerItem, AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -109,53 +108,6 @@ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
|
||||
/** Existing argument/result business components exclude the preparation stage. */
|
||||
export type StartedToolCallViewProps = Exclude<ToolCallViewProps, { readonly phase: 'preparing' }>
|
||||
|
||||
/**
|
||||
* One settled `ask_user_question` call as its transcript row read it. The row
|
||||
* reads the questions from the recorded call JSON, and the answers from
|
||||
* whichever recorded them: its own result when the answer arrived in time, or
|
||||
* the `userQuestions` projection when a late reply settled the call.
|
||||
*/
|
||||
export interface UserQuestionRecord {
|
||||
/** The call's questions, with the option lists the panel renders. */
|
||||
readonly questions: readonly AskUserQuestionItem[]
|
||||
/** The recorded answer batch; an item with no selection and no custom text was skipped. */
|
||||
readonly answers: readonly AskUserQuestionAnswerItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional answer-panel provider consumed by the ask-question row. A timed
|
||||
* `ask_user_question` call stays answerable after its result is recorded, and
|
||||
* its panel can be closed, so the transcript row is the way back to it. A
|
||||
* composition without a question UI has no panel to show.
|
||||
*/
|
||||
export interface UserQuestionPanels {
|
||||
/**
|
||||
* Put one call's answer panel back in the composer, ahead of any other
|
||||
* pending question.
|
||||
* @param sessionId - Session the call belongs to.
|
||||
* @param callId - `ask_user_question` call whose panel to show.
|
||||
* @returns whether a panel for that call was there to show.
|
||||
*/
|
||||
reveal(sessionId: SessionIdOf, callId: string): boolean
|
||||
/**
|
||||
* Show one settled call's recorded answers as a read-only panel, ahead of
|
||||
* any other pending question. Closing that panel drops it for good; the row
|
||||
* builds it again from the same record.
|
||||
* @param sessionId - Session the call belongs to.
|
||||
* @param callId - `ask_user_question` call whose record to show.
|
||||
* @param record - the call's questions and recorded answers.
|
||||
* @returns whether the panel was shown.
|
||||
*/
|
||||
review(sessionId: SessionIdOf, callId: string, record: UserQuestionRecord): boolean
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Optional user-question answer-panel provider. */
|
||||
userQuestionPanels: UserQuestionPanels
|
||||
}
|
||||
}
|
||||
|
||||
/** Injected Host description for POSIX home-path display. */
|
||||
export type ToolHostInfoInjected = {
|
||||
hooks: {
|
||||
|
||||
@@ -3,5 +3,4 @@ export { apply, inject } from './apply.ts'
|
||||
export type {
|
||||
StartedToolCallViewProps, ToolCallCommonProps, ToolCallOwnerProps, ToolCallPhaseProps, ToolCallViewProps,
|
||||
ToolCallHookContext, ToolCallInjected, ToolHostInfoInjected, ToolTreeProps, UseToolCallArgumentsPartial,
|
||||
UserQuestionPanels, UserQuestionRecord,
|
||||
} from './contract/slots.ts'
|
||||
@@ -1,29 +0,0 @@
|
||||
.panelButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 0 10px;
|
||||
height: calc(22px + var(--dsh-content-font-delta, 0px));
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
corner-shape: round;
|
||||
background: var(--dsw-alias-button-floating-fill);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: inherit;
|
||||
font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.panelButton svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.panelButton:hover {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/** Question-owned summary action and transcript disclosure. */
|
||||
import { DisclosureRow, IconInspectOutlineRegular, IconQuestionOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from './ToolRow.tsx'
|
||||
import { formatToolBody } from '../models/tool-call-model.ts'
|
||||
import { AskQuestionCard } from './AskQuestionCard.tsx'
|
||||
import css from './ToolRow.module.css'
|
||||
import questionCss from './QuestionToolRow.module.css'
|
||||
|
||||
type QuestionToolRowProps = Pick<ToolRowProps,
|
||||
'useDisclosure' | 't' | 'summary' | 'bodyRaw' | 'output' | 'askQuestion' | 'state' | 'inspect'> & {
|
||||
readonly openPanel: () => boolean
|
||||
readonly panelLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a question with its answer-panel entry point.
|
||||
* @param props - Question transcript, panel action, and disclosure state.
|
||||
* @returns The question's summary and optional expanded record.
|
||||
*/
|
||||
export function QuestionToolRow({
|
||||
useDisclosure, t, summary, bodyRaw, output, askQuestion, state, inspect, openPanel, panelLabel,
|
||||
}: QuestionToolRowProps) {
|
||||
const { expanded, toggle } = useDisclosure()
|
||||
const expandable = bodyRaw != null || output != null || askQuestion != null
|
||||
const open = expanded && expandable
|
||||
return (
|
||||
<div className={css.root} data-variant="others" data-tool="ask_user_question" data-state={state}>
|
||||
{state === 'running' && <span className={css.visuallyHidden}>{t('row.running')}</span>}
|
||||
<DisclosureRow
|
||||
rowClassName={css.row} leadingClassName={css.leading} titleClassName={css.title}
|
||||
chevronClassName={css.chevron} icon={<IconQuestionOutlineRegular />} title={t('ask.rowTitle')}
|
||||
running={state === 'running'} open={open} expandable={expandable}
|
||||
expandOnRowClick keepContentWhenOpen onToggle={toggle}
|
||||
collapsedContent={<>
|
||||
{summary !== '' && <>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}><TextShimmer active={state === 'running'}>{summary}</TextShimmer></span>
|
||||
</>}
|
||||
<button type="button" className={questionCss.panelButton}
|
||||
onClick={(event) => { event.stopPropagation(); if (!openPanel()) toggle() }}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') event.stopPropagation() }}>
|
||||
<IconQuestionOutlineRegular />{panelLabel}
|
||||
</button>
|
||||
</>}
|
||||
>
|
||||
{open && <div className={css.bodyWrap}>
|
||||
{askQuestion != null ? <AskQuestionCard card={askQuestion} /> : <div className={css.ioCard}>
|
||||
{bodyRaw != null && <div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>{t('row.input')}</span>
|
||||
<span className={css.ioText}>{formatToolBody('others', bodyRaw)}</span>
|
||||
</div>}
|
||||
{bodyRaw != null && output != null && <span className={css.ioDivider} aria-hidden />}
|
||||
{output != null && <div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>{t('row.output')}</span><span className={css.ioText}>{output}</span>
|
||||
</div>}
|
||||
</div>}
|
||||
{inspect !== undefined && <button type="button" className={css.inspectButton} onClick={inspect}>
|
||||
<IconInspectOutlineRegular />{t('row.inspect')}
|
||||
</button>}
|
||||
</div>}
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
import { useCallback } from 'react'
|
||||
import { IconQuestionOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Also merges the userQuestions key into SessionProjectionMap for useProjection.
|
||||
import type {
|
||||
AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption,
|
||||
} from '@deepseek-ai/dsh-user-questions/types'
|
||||
import type { ToolCallViewProps, UserQuestionRecord } from '../../contract/slots.ts'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallViewProps } from '../../contract/slots.ts'
|
||||
import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts'
|
||||
import { singleResultText } from '../models/raw-tool-call.ts'
|
||||
import { toolRowModel } from '../models/tool-call-model.ts'
|
||||
import { ToolRow } from '../components/ToolRow.tsx'
|
||||
import { QuestionToolRow } from '../components/QuestionToolRow.tsx'
|
||||
import { CONVERSATION_NS as NS } from '../../locale.ts'
|
||||
|
||||
/** One result entry after validating the fields used by the transcript card. */
|
||||
interface AnswerEntry {
|
||||
id: string
|
||||
selected: string[]
|
||||
custom?: string
|
||||
}
|
||||
|
||||
/** One question after validating the fields used by the transcript card. */
|
||||
interface QuestionEntry {
|
||||
id: string
|
||||
question: string
|
||||
}
|
||||
|
||||
/** One paired question and its visible answer lines. */
|
||||
interface AnsweredQuestion {
|
||||
id: string
|
||||
@@ -21,13 +28,9 @@ interface AnsweredQuestion {
|
||||
answers: string[]
|
||||
}
|
||||
|
||||
/** Everything a recorded answer batch puts on its row. */
|
||||
interface AnswerPresentation {
|
||||
summary: string
|
||||
/** The paired transcript card; absent when pairing the batch would be ambiguous. */
|
||||
transcript: AskQuestionCardModel | null
|
||||
/** Material for the read-only panel; absent whenever the transcript card is, since both render the same pairing. */
|
||||
record: UserQuestionRecord | undefined
|
||||
questions: AnsweredQuestion[] | null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -43,12 +46,12 @@ function parseJson(text: string): unknown {
|
||||
}
|
||||
|
||||
/** Answer records from the result JSON; null when the result is malformed. */
|
||||
function answerEntries(text: string): AskUserQuestionAnswerItem[] | null {
|
||||
function answerEntries(text: string): AnswerEntry[] | null {
|
||||
const parsed = parseJson(text)
|
||||
if (!isRecord(parsed)) return null
|
||||
const answers = parsed.answers
|
||||
if (!Array.isArray(answers) || !answers.every(isRecord)) return null
|
||||
const entries: AskUserQuestionAnswerItem[] = []
|
||||
const entries: AnswerEntry[] = []
|
||||
for (const answer of answers) {
|
||||
if (typeof answer.id !== 'string'
|
||||
|| !Array.isArray(answer.selected)
|
||||
@@ -63,31 +66,11 @@ function answerEntries(text: string): AskUserQuestionAnswerItem[] | null {
|
||||
return entries
|
||||
}
|
||||
|
||||
/** One question's choices from call JSON; absent when any entry is malformed. */
|
||||
function optionEntries(value: unknown): AskUserQuestionOption[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const options: AskUserQuestionOption[] = []
|
||||
for (const option of value) {
|
||||
if (!isRecord(option) || typeof option.label !== 'string') return undefined
|
||||
options.push({
|
||||
label: option.label,
|
||||
...(typeof option.description === 'string' ? { description: option.description } : {}),
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Questions from call JSON; null when pairing with answers would be ambiguous.
|
||||
* The optional presentation fields are carried for the read-only answer panel,
|
||||
* which renders the same option lists the user chose from; a malformed one is
|
||||
* dropped rather than failing the whole call, because the transcript card needs
|
||||
* only the id and the question text.
|
||||
*/
|
||||
function questionEntries(argsRaw: string): AskUserQuestionItem[] | null {
|
||||
/** Questions from call JSON; null when pairing with answers would be ambiguous. */
|
||||
function questionEntries(argsRaw: string): QuestionEntry[] | null {
|
||||
const parsed = parseJson(argsRaw)
|
||||
if (!isRecord(parsed) || !Array.isArray(parsed.questions) || parsed.questions.length === 0) return null
|
||||
const questions: AskUserQuestionItem[] = []
|
||||
const questions: QuestionEntry[] = []
|
||||
const ids = new Set<string>()
|
||||
for (const question of parsed.questions) {
|
||||
if (!isRecord(question)
|
||||
@@ -95,27 +78,16 @@ function questionEntries(argsRaw: string): AskUserQuestionItem[] | null {
|
||||
|| typeof question.question !== 'string'
|
||||
|| ids.has(question.id)) return null
|
||||
ids.add(question.id)
|
||||
const options = optionEntries(question.options)
|
||||
questions.push({
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
...(typeof question.header === 'string' ? { header: question.header } : {}),
|
||||
...(typeof question.detail === 'string' ? { detail: question.detail } : {}),
|
||||
...(options === undefined ? {} : { options }),
|
||||
// The tool schema spells the multi-select flag in snake case.
|
||||
...(typeof question.multi_select === 'boolean' ? { multiSelect: question.multi_select } : {}),
|
||||
})
|
||||
questions.push({ id: question.id, question: question.question })
|
||||
}
|
||||
return questions
|
||||
}
|
||||
|
||||
/** Pair questions with result entries by their echoed stable ids. */
|
||||
function pairAnswers(
|
||||
questions: readonly AskUserQuestionItem[],
|
||||
answers: readonly AskUserQuestionAnswerItem[],
|
||||
): AnsweredQuestion[] | null {
|
||||
if (questions.length !== answers.length) return null
|
||||
const byId = new Map<string, AskUserQuestionAnswerItem>()
|
||||
function pairAnswers(argsRaw: string, answers: AnswerEntry[]): AnsweredQuestion[] | null {
|
||||
const questions = questionEntries(argsRaw)
|
||||
if (questions === null || questions.length !== answers.length) return null
|
||||
const byId = new Map<string, AnswerEntry>()
|
||||
for (const answer of answers) {
|
||||
if (byId.has(answer.id)) return null
|
||||
byId.set(answer.id, answer)
|
||||
@@ -125,8 +97,7 @@ function pairAnswers(
|
||||
const answer = byId.get(question.id)
|
||||
if (answer === undefined) return null
|
||||
paired.push({
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
...question,
|
||||
answers: [
|
||||
...answer.selected,
|
||||
...(answer.custom === undefined || answer.custom === '' ? [] : [answer.custom]),
|
||||
@@ -136,29 +107,18 @@ function pairAnswers(
|
||||
return paired
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer summary, transcript content, and panel material from one recorded
|
||||
* answer batch, whether the result carried it in time or a late reply did.
|
||||
* @param questions - The call's questions, or null when they failed validation.
|
||||
* @param answers - The recorded answer batch.
|
||||
* @param t - Row translator.
|
||||
* @returns What the answered row shows.
|
||||
*/
|
||||
/** Answer summary plus structured transcript content from the two wire JSON documents. */
|
||||
function answeredPresentation(
|
||||
questions: readonly AskUserQuestionItem[] | null,
|
||||
answers: readonly AskUserQuestionAnswerItem[],
|
||||
argsRaw: string,
|
||||
text: string,
|
||||
t: AskQuestionRowProps['t'],
|
||||
): AnswerPresentation {
|
||||
): AnswerPresentation | null {
|
||||
const answers = answerEntries(text)
|
||||
if (answers === null) return null
|
||||
const answered = answers.filter(answer => answer.selected.length > 0 || (answer.custom ?? '') !== '').length
|
||||
const paired = questions === null ? null : pairAnswers(questions, answers)
|
||||
return {
|
||||
summary: t('ask.answered', { answered, total: answers.length }),
|
||||
transcript: paired === null ? null : { kind: 'answered', questions: paired, skippedLabel: t('ask.skipped') },
|
||||
// The read-only panel renders the option lists the user chose from and
|
||||
// looks each answer up by question id, so a batch this row cannot pair
|
||||
// unambiguously gets no panel either: blank or dropped answers would read
|
||||
// as choices the user made.
|
||||
record: paired === null || questions === null ? undefined : { questions, answers },
|
||||
questions: pairAnswers(argsRaw, answers),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,51 +134,11 @@ function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | nu
|
||||
return t('ask.answered', { answered, total: answers.length })
|
||||
}
|
||||
|
||||
/** Whether a successful tool result records a still-answerable timed question. */
|
||||
function isPendingResult(text: string): boolean {
|
||||
const parsed = parseJson(text)
|
||||
return isRecord(parsed) && parsed.pending === true && typeof parsed.callId === 'string'
|
||||
}
|
||||
|
||||
/** Injected panel verbs for the row's own Session, filled by the optional panel provider. */
|
||||
interface AskQuestionPanelInjected {
|
||||
/**
|
||||
* Show one call's answer panel in the composer.
|
||||
* @param callId - the row's own `ask_user_question` call.
|
||||
* @returns whether a panel for that call was there to show.
|
||||
*/
|
||||
revealPanel: (callId: string) => boolean
|
||||
/**
|
||||
* Show one settled call's recorded answers as a read-only panel.
|
||||
* @param callId - the row's own `ask_user_question` call.
|
||||
* @param record - the call's questions and recorded answers, read from this row.
|
||||
* @returns whether a panel provider was there to show it.
|
||||
*/
|
||||
reviewPanel: (callId: string, record: UserQuestionRecord) => boolean
|
||||
}
|
||||
|
||||
type AskQuestionRowProps = ToolCallViewProps & PropsLocale<'conversation'> & InjectFace<AskQuestionPanelInjected>
|
||||
type AskQuestionRowProps = ToolCallViewProps & PropsLocale<'conversation'>
|
||||
|
||||
/** Summarizes a pending, answered, cancelled, or interrupted question set. */
|
||||
export function AskQuestionRow({
|
||||
callId, toolName, block, inspect, useDisclosure, useProjection, revealPanel, reviewPanel, t,
|
||||
}: AskQuestionRowProps) {
|
||||
export function AskQuestionRow({ toolName, block, inspect, useDisclosure, t }: AskQuestionRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
// The `userQuestions` projection lists exactly the timed calls that can still
|
||||
// take an answer — the running ones and the ones the agent continued past —
|
||||
// so it, not the recorded result, decides whether this row offers its panel.
|
||||
// A recorded pending result stays recorded after the reply, the skip, or the
|
||||
// timeout lands, and a legacy blocking call is never listed: its row reads
|
||||
// exactly as it did before timed questions existed.
|
||||
const answerable = useProjection('userQuestions', view =>
|
||||
view?.active.some(row => row.callId === callId) ?? false)
|
||||
// The same projection settles every answered timed call with its batch: the
|
||||
// one its own result carried, or the one a late reply carried after the
|
||||
// result recorded the timeout. A legacy call is never settled there, so its
|
||||
// answers come from its result text alone, without a panel to read back.
|
||||
const settled = useProjection('userQuestions', view =>
|
||||
view?.settled.find(row => row.callId === callId))
|
||||
const reopen = useCallback(() => revealPanel(callId), [callId, revealPanel])
|
||||
// Composer verdicts settle the call as specific UserQuestionErrors
|
||||
// (ask_user_question handler): 'ASK_CANCELLED' is the user's own
|
||||
// dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the
|
||||
@@ -230,11 +150,6 @@ export function AskQuestionRow({
|
||||
let summary = model.summary
|
||||
let state = model.state
|
||||
let transcript: AskQuestionCardModel | null = null
|
||||
// Every row keeps a pill back to its panel: an answerable call reopens the
|
||||
// live one, and a settled call opens the same panel read-only over its
|
||||
// recorded answers.
|
||||
let rowAction: (() => boolean) | undefined
|
||||
let rowActionLabel = t('ask.reopen')
|
||||
if (code === 'ASK_CANCELLED') {
|
||||
summary = t('ask.cancelled')
|
||||
state = 'ok'
|
||||
@@ -251,51 +166,18 @@ export function AskQuestionRow({
|
||||
}
|
||||
} else if (model.state === 'running') {
|
||||
summary = t('ask.waiting')
|
||||
if (answerable) rowAction = reopen
|
||||
} else if ('kind' in block && model.state === 'ok') {
|
||||
const text = singleResultText(block)
|
||||
if (text !== undefined) {
|
||||
const questions = questionEntries(argsRaw)
|
||||
// A timed question's recorded result may be the timeout; whether it is
|
||||
// still answerable, and what the user finally answered, is the
|
||||
// projection's to say.
|
||||
const pending = isPendingResult(text)
|
||||
if (pending && answerable) {
|
||||
summary = t('ask.pending')
|
||||
if (questions !== null) {
|
||||
transcript = { kind: 'unanswered', questions, verdict: t('ask.pendingDetail') }
|
||||
}
|
||||
rowAction = reopen
|
||||
} else if (pending && (settled === undefined || settled.answers.length === 0)) {
|
||||
summary = t('ask.closed')
|
||||
if (questions !== null) {
|
||||
transcript = { kind: 'unanswered', questions, verdict: t('ask.closedDetail') }
|
||||
}
|
||||
} else {
|
||||
// Answered either way: whether the answer beat the timeout is the
|
||||
// agent's pacing, not something a reader of the row has to reason
|
||||
// about, so a late-answered call reads exactly like an in-time one.
|
||||
const answers = settled?.answers ?? answerEntries(text)
|
||||
const presentation = answers === null ? null : answeredPresentation(questions, answers, t)
|
||||
// Full transcripts require stable ids and valid visible fields; retain the
|
||||
// legacy best-effort count when only strict pairing is unsafe.
|
||||
summary = presentation?.summary ?? answeredSummary(text, t) ?? model.summary
|
||||
transcript = presentation?.transcript ?? null
|
||||
// Only a timed call has a panel to read its answers back in.
|
||||
const record = settled === undefined ? undefined : presentation?.record
|
||||
if (record !== undefined) {
|
||||
rowAction = () => reviewPanel(callId, record)
|
||||
rowActionLabel = t('ask.review')
|
||||
}
|
||||
const presentation = answeredPresentation(argsRaw, text, t)
|
||||
// Full transcripts require stable ids and valid visible fields; retain the
|
||||
// legacy best-effort count when only strict pairing is unsafe.
|
||||
summary = presentation?.summary ?? answeredSummary(text, t) ?? model.summary
|
||||
if (presentation?.questions !== null && presentation?.questions !== undefined) {
|
||||
transcript = { kind: 'answered', questions: presentation.questions, skippedLabel: t('ask.skipped') }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rowAction !== undefined) return (
|
||||
<QuestionToolRow useDisclosure={useDisclosure} t={t} summary={summary}
|
||||
bodyRaw={transcript === null ? model.bodyRaw : null} output={transcript === null ? model.output : null}
|
||||
askQuestion={transcript} state={state} inspect={inspect}
|
||||
openPanel={rowAction} panelLabel={rowActionLabel} />
|
||||
)
|
||||
return (
|
||||
<ToolRow
|
||||
useDisclosure={useDisclosure}
|
||||
@@ -321,13 +203,6 @@ export const askQuestionToolview = {
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
|
||||
name: 'tool.call.toolview', key: 'ask_user_question', locale: NS,
|
||||
// Read per click, not per render: a composition with no question UI
|
||||
// provides no panels, and the row then falls back to its own record.
|
||||
inject: (sessionId): AskQuestionPanelInjected => ({
|
||||
revealPanel: callId => ctx.get('userQuestionPanels')?.reveal(sessionId, callId) ?? false,
|
||||
reviewPanel: (callId, record) =>
|
||||
ctx.get('userQuestionPanels')?.review(sessionId, callId, record) ?? false,
|
||||
}),
|
||||
}, AskQuestionRow))
|
||||
},
|
||||
}
|
||||
@@ -2,22 +2,18 @@
|
||||
/**
|
||||
* ask_user_question toolview acceptance: `waiting` summary while running,
|
||||
* answered-count from the result JSON once settled (skipped answers
|
||||
* excluded), the same reading for a call a late reply answered, readable
|
||||
* question lists for ASK_CANCELLED and ASK_ABORTED, the still-answerable
|
||||
* row's reopen action and its closed counterpart, shared ToolRow state
|
||||
* semantics for interrupted/failed calls, and generic fallbacks on malformed
|
||||
* results.
|
||||
* excluded), readable question lists for ASK_CANCELLED and ASK_ABORTED,
|
||||
* shared ToolRow state semantics for interrupted/failed calls, and generic
|
||||
* fallbacks on malformed results.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { AskQuestionRow, askQuestionToolview } from '../src/client/tool/toolviews/ask-question-row.tsx'
|
||||
import type { UserQuestionPanels } from '../src/client/contract/slots.ts'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -28,7 +24,6 @@ const READABLE_ARGS = JSON.stringify({ questions: [
|
||||
{ id: 'scope', question: 'Which project should this apply to?' },
|
||||
{ id: 'notes', question: 'Anything else?' },
|
||||
] })
|
||||
const PENDING_RESULT = JSON.stringify({ pending: true, callId: 'c1' })
|
||||
|
||||
const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
|
||||
@@ -37,87 +32,22 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<T
|
||||
isError: false, subCalls: [], ...over,
|
||||
})
|
||||
|
||||
const runningCall = (argsRaw: string): ToolCallBlock =>
|
||||
({ phase: 'start', callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] })
|
||||
const runningCall = (argsRaw: string) =>
|
||||
({ phase: 'start' as const, callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] })
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
/** What the row's environment says about the call, beyond the recorded block. */
|
||||
interface RowEnvironment {
|
||||
/** Call ids the `userQuestions` projection still lists as answerable; absent = none. */
|
||||
answerable?: readonly string[]
|
||||
/** Answer batches the projection settled a late reply with, by call id; absent = none. */
|
||||
settled?: Readonly<Record<string, readonly unknown[]>>
|
||||
/** The injected reopen verb; absent = a panel is always there to show. */
|
||||
revealPanel?: (callId: string) => boolean
|
||||
/** The injected read-only verb; absent = a provider is always there. */
|
||||
reviewPanel?: (callId: string, record: unknown) => boolean
|
||||
}
|
||||
|
||||
function rowProps(block: ToolCallBlock, env: RowEnvironment = {}): Parameters<typeof AskQuestionRow>[0] {
|
||||
const view = {
|
||||
active: (env.answerable ?? []).map(callId => ({ callId })),
|
||||
settled: Object.entries(env.settled ?? {}).map(([callId, answers]) => ({ callId, answers })),
|
||||
}
|
||||
const useProjection = (_key: string, select: (value: unknown) => unknown) => select(view)
|
||||
const props = {
|
||||
useDisclosure, callId: 'c1', toolName: 'ask_user_question', t,
|
||||
...('kind' in block ? { phase: 'result', block } : { phase: block.phase, block }),
|
||||
useToolCallArgumentsPartial: () => '',
|
||||
function rowProps(block: Parameters<typeof AskQuestionRow>[0]['block']): Parameters<typeof AskQuestionRow>[0] {
|
||||
return {
|
||||
useDisclosure, callId: 'c1', toolName: 'ask_user_question', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), t,
|
||||
openFile: vi.fn(),
|
||||
loadImage: vi.fn(async () => ''),
|
||||
sessionId: 's1' as SessionId,
|
||||
useSession: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
useProjection,
|
||||
revealPanel: env.revealPanel ?? (() => true),
|
||||
reviewPanel: env.reviewPanel ?? (() => true),
|
||||
} as Parameters<typeof AskQuestionRow>[0]
|
||||
return props
|
||||
}
|
||||
|
||||
const answers = (entries: unknown[]): string => JSON.stringify({ answers: entries })
|
||||
|
||||
/** Every pill the row can offer, by its visible copy. */
|
||||
const PILL = { reopen: '回答', review: '查看回答' } as const
|
||||
|
||||
/** The panel verbs the registered toolview hands each row of one Session. */
|
||||
interface PanelFace {
|
||||
revealPanel: (callId: string) => boolean
|
||||
reviewPanel: (callId: string, record: unknown) => boolean
|
||||
}
|
||||
|
||||
/** The keyed toolview declaration, as far as this spec reads it. */
|
||||
interface ToolviewDeclaration {
|
||||
inject?: (sessionId: string) => PanelFace
|
||||
}
|
||||
|
||||
/** The optional panel provider, as far as this spec calls it. */
|
||||
type PanelProvider = UserQuestionPanels
|
||||
|
||||
/**
|
||||
* Apply the plugin against a fake root and return the face it injects into a
|
||||
* row of Session `s1`.
|
||||
* @param get - the root's optional-service lookup.
|
||||
* @returns the injected panel verbs.
|
||||
*/
|
||||
function injectedFace(get: (name: string) => PanelProvider | undefined): PanelFace {
|
||||
let declaration: ToolviewDeclaration | undefined
|
||||
askQuestionToolview.apply({
|
||||
get,
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => () => void) => callback(),
|
||||
register: (declared: ToolviewDeclaration) => {
|
||||
declaration = declared
|
||||
return () => undefined
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
const face = declaration?.inject?.('s1')
|
||||
if (face === undefined) throw new Error('the toolview registered no injected face')
|
||||
return face
|
||||
}
|
||||
|
||||
describe('AskQuestionRow', () => {
|
||||
it('running call reads waiting (args-independent: the composer takeover shows the questions)', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(runningCall(ARGS))} />)
|
||||
@@ -126,129 +56,6 @@ describe('AskQuestionRow', () => {
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a running call the projection still lists offers its panel from the row body', () => {
|
||||
const revealPanel = vi.fn(() => true)
|
||||
render(<AskQuestionRow {...rowProps(runningCall(ARGS), { answerable: ['c1'], revealPanel })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: PILL.reopen }))
|
||||
expect(revealPanel).toHaveBeenCalledWith('c1')
|
||||
})
|
||||
|
||||
it('a pending row keeps the disclosure on its leading glyph while the body reopens the panel', () => {
|
||||
const revealPanel = vi.fn(() => true)
|
||||
render(<AskQuestionRow {...rowProps(
|
||||
resultNode(READABLE_ARGS, PENDING_RESULT),
|
||||
{ answerable: ['c1'], revealPanel },
|
||||
)} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
expect(revealPanel).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: PILL.reopen }))
|
||||
expect(revealPanel).toHaveBeenCalledWith('c1')
|
||||
// Reopening is not a disclosure: the body the user already opened stays open.
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keyboard activation of the pill leaves the row disclosure closed', () => {
|
||||
// The pill is a button inside the row's own button: without the keydown
|
||||
// guard, one Enter would both reopen the panel and expand the record.
|
||||
const revealPanel = vi.fn(() => true)
|
||||
render(<AskQuestionRow {...rowProps(
|
||||
resultNode(READABLE_ARGS, PENDING_RESULT),
|
||||
{ answerable: ['c1'], revealPanel },
|
||||
)} />)
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: PILL.reopen }), { key: 'Enter' })
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a reopen that finds no panel left expands the record instead of doing nothing', () => {
|
||||
// The projection lists the call but no card answers for it — a resumed
|
||||
// Session before its repair pass, or a composition with no question UI.
|
||||
const revealPanel = vi.fn(() => false)
|
||||
render(<AskQuestionRow {...rowProps(
|
||||
resultNode(READABLE_ARGS, PENDING_RESULT),
|
||||
{ answerable: ['c1'], revealPanel },
|
||||
)} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: PILL.reopen }))
|
||||
expect(revealPanel).toHaveBeenCalledWith('c1')
|
||||
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
|
||||
expect(screen.getByText('What do you want to accomplish?')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a pending result the projection dropped reads as closed, with no action on its row', () => {
|
||||
// The recorded result stays `pending` after the reply, the skip, or the
|
||||
// timeout lands; the projection is what says the question is over.
|
||||
render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, PENDING_RESULT))} />)
|
||||
|
||||
expect(screen.getByText('已结束')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: PILL.reopen })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: PILL.review })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByText('该问题已结束,结果见下方对话')).toBeTruthy()
|
||||
expect(screen.getByText('What do you want to accomplish?')).toBeTruthy()
|
||||
expect(screen.queryByText(/"pending"/)).toBeNull()
|
||||
})
|
||||
|
||||
it('a late reply makes its row read exactly like one answered in time', () => {
|
||||
// The timed call's own result is the timeout, so the answers come from the
|
||||
// projection; which one beat the clock is the agent's pacing, not the
|
||||
// reader's problem.
|
||||
const reviewPanel = vi.fn(() => true)
|
||||
const answered = [
|
||||
{ id: 'goal', selected: ['Develop a feature'] },
|
||||
{ id: 'scope', selected: [], custom: 'the web app' },
|
||||
{ id: 'notes', selected: [] },
|
||||
]
|
||||
render(<AskQuestionRow {...rowProps(
|
||||
resultNode(READABLE_ARGS, PENDING_RESULT),
|
||||
{ settled: { c1: answered }, reviewPanel },
|
||||
)} />)
|
||||
|
||||
expect(screen.getByText('2/3 已回答')).toBeTruthy()
|
||||
expect(screen.queryByText('已结束')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: PILL.reopen })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByText('What do you want to accomplish?')).toBeTruthy()
|
||||
expect(screen.getByText('the web app')).toBeTruthy()
|
||||
expect(screen.getByText('未回答')).toBeTruthy()
|
||||
expect(screen.queryByText(/"pending"/)).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: PILL.review }))
|
||||
expect(reviewPanel).toHaveBeenCalledWith('c1', {
|
||||
questions: [
|
||||
{ id: 'goal', question: 'What do you want to accomplish?' },
|
||||
{ id: 'scope', question: 'Which project should this apply to?' },
|
||||
{ id: 'notes', question: 'Anything else?' },
|
||||
],
|
||||
answers: answered,
|
||||
})
|
||||
})
|
||||
|
||||
it('a reply that settled the call without answers leaves the row closed', () => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, PENDING_RESULT), { settled: { c1: [] } })} />)
|
||||
expect(screen.getByText('已结束')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: PILL.review })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: PILL.reopen })).toBeNull()
|
||||
})
|
||||
|
||||
it('a still-answerable row keeps its reopen pill even after another call was settled', () => {
|
||||
render(<AskQuestionRow {...rowProps(
|
||||
resultNode(READABLE_ARGS, PENDING_RESULT),
|
||||
{ answerable: ['c1'], settled: { other: [{ id: 'goal', selected: ['x'] }] } },
|
||||
)} />)
|
||||
expect(screen.getByRole('button', { name: PILL.reopen })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: PILL.review })).toBeNull()
|
||||
})
|
||||
|
||||
it('another session question being answered leaves this row alone', () => {
|
||||
render(<AskQuestionRow {...rowProps(runningCall(ARGS), { answerable: ['other'] })} />)
|
||||
expect(screen.queryByRole('button', { name: PILL.reopen })).toBeNull()
|
||||
})
|
||||
|
||||
it('settled result counts answered entries (selected choices or custom text)', () => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
|
||||
{ id: 'a', selected: ['x'] },
|
||||
@@ -258,20 +65,6 @@ describe('AskQuestionRow', () => {
|
||||
expect(screen.getByText('3/3 已回答')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows a pending result as readable still-answerable questions', () => {
|
||||
const view = render(<AskQuestionRow {...rowProps(
|
||||
resultNode(READABLE_ARGS, PENDING_RESULT),
|
||||
{ answerable: ['c1'] },
|
||||
)} />)
|
||||
|
||||
expect(screen.getByText('已继续工作,仍可回答')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByText('等待中的问题仍可在输入框中回答')).toBeTruthy()
|
||||
expect(screen.getByText('What do you want to accomplish?')).toBeTruthy()
|
||||
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
|
||||
expect(screen.queryByText(/"pending"/)).toBeNull()
|
||||
})
|
||||
|
||||
it('expands a successful result as paired questions and readable answer lines', () => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, answers([
|
||||
{ id: 'scope', selected: ['deepseek-harness'] },
|
||||
@@ -292,91 +85,6 @@ describe('AskQuestionRow', () => {
|
||||
expect(screen.queryByText(/"answers"/)).toBeNull()
|
||||
})
|
||||
|
||||
it('a timed call the projection settled in time offers its record as a read-only panel, options included', () => {
|
||||
const args = JSON.stringify({ questions: [{
|
||||
id: 'goal',
|
||||
question: 'What do you want to accomplish?',
|
||||
header: 'Goal',
|
||||
detail: 'Pick the closest match.',
|
||||
multi_select: false,
|
||||
options: [{ label: 'Develop a feature', description: 'New behavior' }, { label: 'Fix a bug' }],
|
||||
}] })
|
||||
const reviewPanel = vi.fn(() => true)
|
||||
const batch = [{ id: 'goal', selected: ['Fix a bug'] }]
|
||||
render(<AskQuestionRow {...rowProps(
|
||||
resultNode(args, answers(batch)),
|
||||
{ settled: { c1: batch }, reviewPanel },
|
||||
)} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: PILL.review }))
|
||||
expect(reviewPanel).toHaveBeenCalledWith('c1', {
|
||||
questions: [{
|
||||
id: 'goal',
|
||||
question: 'What do you want to accomplish?',
|
||||
header: 'Goal',
|
||||
detail: 'Pick the closest match.',
|
||||
multiSelect: false,
|
||||
options: [{ label: 'Develop a feature', description: 'New behavior' }, { label: 'Fix a bug' }],
|
||||
}],
|
||||
answers: batch,
|
||||
})
|
||||
})
|
||||
|
||||
it('an answered legacy call reads exactly as before timed questions existed: transcript, no pill', () => {
|
||||
// The projection never lists a call the blocking legacy tool made, so the
|
||||
// row has nothing to reopen and no panel to read the answers back in.
|
||||
const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, answers([
|
||||
{ id: 'goal', selected: ['Develop a feature'] },
|
||||
{ id: 'scope', selected: [], custom: 'the web app' },
|
||||
{ id: 'notes', selected: [] },
|
||||
])))} />)
|
||||
|
||||
expect(screen.getByText('2/3 已回答')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: PILL.review })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: PILL.reopen })).toBeNull()
|
||||
expect(view.container.querySelector('[class*="rowAction"]')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(screen.getByText('the web app')).toBeTruthy()
|
||||
expect(screen.getByText('未回答')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a settled call whose questions failed validation keeps only its transcript', () => {
|
||||
const batch = [{ id: 'a', selected: ['x'] }]
|
||||
render(<AskQuestionRow {...rowProps(resultNode('oops', answers(batch)), { settled: { c1: batch } })} />)
|
||||
expect(screen.getByText('1/1 已回答')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: PILL.review })).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'fewer answers than questions', batch: [{ id: 'goal', selected: ['x'] }] },
|
||||
{ label: 'an answer to a question never asked', batch: [
|
||||
{ id: 'goal', selected: ['x'] }, { id: 'scope', selected: [] }, { id: 'other', selected: ['y'] },
|
||||
] },
|
||||
{ label: 'the same question answered twice', batch: [
|
||||
{ id: 'goal', selected: ['x'] }, { id: 'goal', selected: ['y'] }, { id: 'notes', selected: [] },
|
||||
] },
|
||||
])('a settled batch with $label offers no panel, like the transcript it cannot pair', ({ batch }) => {
|
||||
const reviewPanel = vi.fn(() => true)
|
||||
const view = render(<AskQuestionRow {...rowProps(
|
||||
resultNode(READABLE_ARGS, answers(batch)),
|
||||
{ settled: { c1: batch }, reviewPanel },
|
||||
)} />)
|
||||
expect(screen.queryByRole('button', { name: PILL.review })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
|
||||
expect(reviewPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'a cancelled set', code: 'ASK_CANCELLED' },
|
||||
{ label: 'an aborted turn', code: 'ASK_ABORTED' },
|
||||
])('$label has no answers to review', ({ code }) => {
|
||||
render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, null,
|
||||
{ isError: true, error: { name: 'UserQuestionError', code } }))} />)
|
||||
expect(screen.queryByRole('button', { name: PILL.review })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: PILL.reopen })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps generic diagnostics when a valid answer result includes a non-text block', () => {
|
||||
const resultText = answers([
|
||||
{ id: 'goal', selected: ['Develop a feature'] },
|
||||
@@ -530,44 +238,13 @@ describe('AskQuestionRow', () => {
|
||||
it('askQuestionToolview injects the toolview declaration directly', () => {
|
||||
expect(askQuestionToolview.name).toBe('ask-question-toolview')
|
||||
expect(askQuestionToolview.inject).toEqual(['slots'])
|
||||
const register = vi.fn((_declaration: ToolviewDeclaration, _view: unknown) => () => undefined)
|
||||
const register = vi.fn(() => () => undefined)
|
||||
const inject = vi.fn((_name: string, callback: () => () => void) => callback())
|
||||
askQuestionToolview.apply({ slots: { inject, register }, get: () => undefined } as never)
|
||||
askQuestionToolview.apply({ slots: { inject, register } } as never)
|
||||
expect(inject).toHaveBeenCalledWith('tool.call.toolview', expect.any(Function))
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'tool.call.toolview', key: 'ask_user_question', locale: 'conversation',
|
||||
inject: expect.any(Function) as ToolviewDeclaration['inject'],
|
||||
},
|
||||
{ name: 'tool.call.toolview', key: 'ask_user_question', locale: 'conversation' },
|
||||
AskQuestionRow,
|
||||
)
|
||||
})
|
||||
|
||||
it('the injected reveal verb asks the optional panel provider for the row own session', () => {
|
||||
const reveal = vi.fn<UserQuestionPanels['reveal']>(() => true)
|
||||
const get = vi.fn((_name: string): PanelProvider => ({
|
||||
reveal: (_sessionId, callId) => reveal(_sessionId, callId),
|
||||
review: () => false,
|
||||
}))
|
||||
expect(injectedFace(get).revealPanel('c1')).toBe(true)
|
||||
expect(get).toHaveBeenCalledWith('userQuestionPanels')
|
||||
expect(reveal).toHaveBeenCalledWith('s1', 'c1')
|
||||
})
|
||||
|
||||
it('the injected review verb hands the row own record to the panel provider', () => {
|
||||
const review = vi.fn<UserQuestionPanels['review']>(() => true)
|
||||
const get = vi.fn((_name: string): PanelProvider => ({
|
||||
reveal: () => false,
|
||||
review: (_sessionId, callId, value) => review(_sessionId, callId, value),
|
||||
}))
|
||||
const record = { questions: [], answers: [] }
|
||||
expect(injectedFace(get).reviewPanel('c1', record)).toBe(true)
|
||||
expect(get).toHaveBeenCalledWith('userQuestionPanels')
|
||||
expect(review).toHaveBeenCalledWith('s1', 'c1', record)
|
||||
})
|
||||
|
||||
it('a composition with no question UI reports no panel to reveal or review', () => {
|
||||
expect(injectedFace(() => undefined).revealPanel('c1')).toBe(false)
|
||||
expect(injectedFace(() => undefined).reviewPanel('c1', { questions: [], answers: [] })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@ import { toolRowModel } from '../src/client/tool/models/tool-call-model.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
type Props = Parameters<typeof TodoRow>[0] & Parameters<typeof ReadImageRow>[0] & Parameters<typeof AskQuestionRow>[0]
|
||||
type Props = Parameters<typeof TodoRow>[0] & Parameters<typeof ReadImageRow>[0]
|
||||
|
||||
function preparation(name: string): Props {
|
||||
return {
|
||||
@@ -29,7 +29,6 @@ function preparation(name: string): Props {
|
||||
block: { phase: 'preparing', callId: 'call', name, turn: 1, step: 1, time: 1, subCalls: [] },
|
||||
t: makeTranslate(en, common), useDisclosure, useToolCallArgumentsPartial: vi.fn(() => ''), openFile: vi.fn(), loadImage: vi.fn(),
|
||||
useTodoHistory: vi.fn(), useSession: vi.fn(() => false), renderSlot: vi.fn(() => null),
|
||||
useProjection: vi.fn(() => undefined), revealPanel: vi.fn(() => false), reviewPanel: vi.fn(() => false),
|
||||
} as Props
|
||||
}
|
||||
|
||||
|
||||
@@ -50,9 +50,6 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-questions"
|
||||
},
|
||||
{
|
||||
"path": "../../util/workspace-path"
|
||||
}
|
||||
|
||||
@@ -3,47 +3,41 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-user-questions/README.md
|
||||
/:
|
||||
en: 50286bfa65e45456
|
||||
zh: 2347492bb65bd1b4
|
||||
en: b50469addda9db2d
|
||||
zh: ae560611295a5f4d
|
||||
/deepseek-ai-dsh-client-ui-user-questions:
|
||||
en: f109e91b29d71f55
|
||||
zh: 73200984bad7f908
|
||||
/deepseek-ai-dsh-client-ui-user-questions/summary:
|
||||
en: 18a3bcddc768cf47
|
||||
zh: e5d27e49b85d54be
|
||||
en: 7e81c6a53759ff37
|
||||
zh: b83f666c4ef4cbd3
|
||||
/deepseek-ai-dsh-client-ui-user-questions/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-user-questions/use-this-package:
|
||||
en: 636c8c2ba17dbdc3
|
||||
zh: 88f64832d26eb076
|
||||
en: 4aa048dcb7282f12
|
||||
zh: d2731da0870aa6a0
|
||||
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering:
|
||||
en: cc19c065d0aeac1e
|
||||
zh: 91c8cbe08b438e7f
|
||||
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back:
|
||||
en: 10975f68ff9c6fcc
|
||||
zh: 92246aadc363f0eb
|
||||
en: 8b6f37bfb11a2c3a
|
||||
zh: 59d8951211f9b21b
|
||||
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/the-plan-review-card:
|
||||
en: 6b12f1362c614a39
|
||||
zh: 27d9cf54d4cbd5d2
|
||||
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/failure-and-recovery:
|
||||
en: 74c7205ff3342e20
|
||||
zh: d89f60a2aec7801b
|
||||
en: da530ef485c07417
|
||||
zh: b07525cfcd5034f0
|
||||
/deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation:
|
||||
en: 77e1b2585acdf67b
|
||||
zh: d433ec185c6b8f57
|
||||
/deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation/reopening-a-panel:
|
||||
en: 4350f2fbc41f6c86
|
||||
zh: a11ef5e97d0c936e
|
||||
/deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation/intent-surface-election:
|
||||
en: 126a889edbc42edd
|
||||
zh: 7e08968cd61d42a2
|
||||
en: 7f3e0e2460317462
|
||||
zh: cb6b9410410e2f29
|
||||
/deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation/copy-and-locale:
|
||||
en: 00b3f92cdad70faf
|
||||
zh: ab672ddc2faef857
|
||||
en: 1a144dbf8bf39d43
|
||||
zh: 66bccf9c343395c7
|
||||
/deepseek-ai-dsh-client-ui-user-questions/further-exploration:
|
||||
en: dc98d242987579a3
|
||||
zh: a193b9f33a197089
|
||||
en: 70e1ae005fa86e3c
|
||||
zh: 9c779d63b3f921cc
|
||||
/deepseek-ai-dsh-client-ui-user-questions/model-experience:
|
||||
en: 198421eb8ed8923f
|
||||
zh: 84f719be6e6dfe62
|
||||
@@ -51,8 +45,8 @@
|
||||
en: 1032ba29b7d7f9d7
|
||||
zh: 01f1dbf4c2164fd2
|
||||
/deepseek-ai-dsh-client-ui-user-questions/known-limitations-and-deferred-work:
|
||||
en: d61767a407077aaf
|
||||
zh: 891e7bffa61f3017
|
||||
en: beb6dc6d1496e799
|
||||
zh: d92150a88a797c21
|
||||
/deepseek-ai-dsh-client-ui-user-questions/known-limitations-and-deferred-work/dev-note:
|
||||
en: 5dd8c89b228f4037
|
||||
zh: cf8d2774370d1b74
|
||||
en: 7c763299daed17e4
|
||||
zh: fb410b366b407ec7
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Web ask_user_question feature for the dsh web client: the attached question card, timed wait, drafts, late replies, and the plan-review approval card."
|
||||
description: "Web ask_user_question feature for the dsh web client: the composer-takeover question UI and the plan-review approval card."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
This package presents an agent's question in the Web client as an interactive card attached to the chat input. Users navigate questions, choose options, enter custom answers, skip items, and submit one structured batch. A timed question shows a countdown; pristine focus pauses it, the first edit or `Take time` holds it indefinitely, and an untouched countdown lets the agent continue while the card stays answerable. Closing the card puts its panel away without answering; the `ask_user_question` tool call row reopens it, read-only over the recorded answers once the question settled. It survives navigation, reload, and machine restart.
|
||||
When an agent asks a question in the Web client, this package replaces the chat composer with an interactive question surface. Users can move through questions, choose one or multiple options, enter custom answers, skip items, and submit one structured answer batch. Single-choice selections advance immediately, while drafts survive Session navigation for the lifetime of the page. A single question with a supported presentation intent can use a dedicated surface, including the plan-review card with `Request changes` and `Approve` actions.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,17 +25,11 @@ This package presents an agent's question in the Web client as an interactive ca
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
When the agent asks a question, the attached card temporarily occupies the composer seat: answer each question, navigate with the pager, or skip it. Single-select choices advance immediately; Enter continues the flow and submits once every question is answered or skipped, while Shift+Enter breaks a line instead (during IME composition Enter only confirms the input candidate without advancing).
|
||||
When the agent asks a question, the composer becomes the question surface: answer each question, navigate with the pager, or skip it. Single-select choices advance immediately; Enter continues the flow and submits once every question is answered or skipped, while Shift+Enter breaks a line instead (during IME composition Enter only confirms the input candidate without advancing).
|
||||
|
||||
### Answering
|
||||
|
||||
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
### Reading a settled question back
|
||||
|
||||
Each late reply is shown once, including when it opens a new Turn. Replies follow normal Chat process grouping and whole-Turn folding. Expand the containing Turn and work group to read a reply that has been folded away.
|
||||
|
||||
A question that already settled opens as a read-only card: the same pager over the recorded answers, marked `Answered`. Every option and text field is disabled, Skip and the submit action are gone, a question the user skipped says so in place of its empty answer, and a free-text field appears only where the recorded answer used one. Closing it removes the card, and the tool call row builds another from the same record.
|
||||
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
### The plan-review card
|
||||
|
||||
@@ -43,19 +37,7 @@ A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review
|
||||
|
||||
### Failure and recovery
|
||||
|
||||
An answer field autofocuses only when its answer channel is ready and no countdown exists. Manual focus and blur during the claim handshake are retained: the countdown starts paused only if the answer surface still has focus when the remaining duration arrives.
|
||||
|
||||
Returning a local answer does not release its foreground claim: the Host closes that stream after accepting the waterfall outcome. The plugin owns the claim through this delivery interval. Explicit delegation releases it before calling the next answerer, and plugin disposal cancels any remaining claims.
|
||||
|
||||
The card has two sources. A live Host request creates it and carries the answer back. For a timed request, the Client first opens the question-owned `attachWait` stream and derives a local deadline from its remaining-duration frame. The claim lasts until that request settles or the Client disconnects, including while the panel is hidden. The card owns the countdown; at zero it rejects with `ASK_TIMED_OUT` and stays available. Once the projection lists the call as continued, answers use the `answer` Remote method. Card removal follows the projection, and plugin teardown releases its requests and claims.
|
||||
|
||||
A submission through the pending request is sent, not confirmed: when another browser settled the same request first, the gateway drops the late outcome silently. The card therefore keeps its draft until the projection closes it, and if the call turns continued while a submission is in flight, the controls re-arm with a hint so the same draft goes through the Remote path. Pristine focus freezes this browser's remaining countdown and blur resumes that remainder. The first answer mutation changes the local wait to indefinite, and `Take time` does the same explicitly. Those choices persist with this browser's draft across Session navigation and reload. Another browser has its own countdown and can still settle the shared request; the Host projection remains the authority for whether the question is open or continued.
|
||||
|
||||
Each Session and pending request has an independent draft in browser storage. Switching Sessions, reloading the page, or restarting the same browser restores unfinished input; a hidden panel keeps its draft for the next time it is opened, a closed card clears its own, and a later card prunes drafts no card owns. Drafts do not synchronize to another browser or device; the Host-projected question does.
|
||||
|
||||
A browser that reconnects while the tool call is open receives the pending request again and can still complete it with the remaining time. A Session reopened after the agent or process ended shows the card as continued; submitting resumes the Session's root agent and enters the answer as a new user turn.
|
||||
|
||||
Settled question replies remain compact in chat history. A caret marks the bubble as a disclosure; clicking it reopens the read-only question and answer details, and never reopens the settled tool call for a second submission. Its copy action writes each question with the answer the user gave, the same text whether the bubble is open or closed.
|
||||
The generic question flow keeps its current page, selected labels, custom text, and explicit skips in a non-persisted Slot store scoped to the owning Session generation and keyed by the pending request's local render identity. Switching from Session A to B retires A when no other reference owns it, so returning to A starts an empty question draft; another reference that keeps A's generation alive also keeps that draft. A different request identity reads an empty draft and replaces the previous value on its first edit; a successful answer or cancellation clears the matching value. The host remains authoritative for whether the request is pending.
|
||||
|
||||
-----
|
||||
|
||||
@@ -67,20 +49,16 @@ Settled question replies remain compact in chat history. A caret marks the bubbl
|
||||
|
||||
The package is one ownership rule: rendering a question is a host UI capability, having the tool is an agent capability, so the `tool-ask-user` row belongs to the presets that want it (and to the TUI composition, which has no presets).
|
||||
|
||||
### Reopening a panel
|
||||
|
||||
This package fills `userQuestionPanels`, the optional capability `dsh-client-ui-tool` declares for its `ask_user_question` row. `reveal(sessionId, callId)` republishes that call's card as the last equal-precedence pending interaction, so the composer seat shows it again, and returns `false` when this Client holds no card for the call — a legacy unkeyed request, another browser's question, or a card the projection already closed. Whether a question still takes an answer comes from the `userQuestions` Session projection, which the row reads directly, so the capability carries no state of its own.
|
||||
|
||||
`review(sessionId, callId, record)` builds a card from the questions and answers the row parsed out of its own transcript, because the projection lists only answerable calls. That card has no answer channel and no countdown, the projection sweep leaves it alone, and closing it removes it rather than parking it in the registry. A call that still holds a live card shows the live one, so a stale copy never replaces a standing request; for the same reason a review card renders the record itself and takes only the page position from a draft its live card left behind.
|
||||
|
||||
### Intent surface election
|
||||
|
||||
The card accepts one question declaring the intent, carrying the plan as `detail`, and offering the named approve label, with at most one alternative and no multi-select. Its secondary action returns to the composer for change requests. Larger choices and multi-select questions remain in the generic flow. A plan review exposes `conversation.plan-review.actions` with its request key, full text, and optional invocation identity; the plan plugin opens logged plans from history and unlogged reviews as temporary sidebar previews, and opening a document does not answer or dismiss the review.
|
||||
The card accepts one question declaring the intent, carrying the plan as `detail`, and offering the named approve label, with at most one alternative and no multi-select. Its secondary action returns to the composer for change requests. Larger choices and multi-select questions remain in the generic flow.
|
||||
|
||||
### Copy and locale
|
||||
|
||||
Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.
|
||||
|
||||
A plan review exposes `conversation.plan-review.actions` with its request key, full text, and optional invocation identity. The plan plugin opens logged plans from history and unlogged reviews as temporary sidebar previews. Opening a document does not answer or dismiss the review.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -90,8 +68,7 @@ Composer chrome copy (pager, buttons, placeholders, validation feedback) is bili
|
||||
|
||||
These pages cover the composer host, the tool seam, and the plan-mode consumer.
|
||||
|
||||
- [ui-conversation](../ui-conversation/README.md) — the chat surface owning the `conversation.composer` slot.
|
||||
- [ui-tool](../ui-tool/README.md) — the tool call transcript whose `ask_user_question` row reopens a hidden panel.
|
||||
- [ui-conversation](../ui-conversation/README.md) — the chat surface owning the `conversation.composer` chain.
|
||||
- [tool-ask-user](../../interaction/tool-ask-user/README.md) — the model-facing tool whose schema and answers this UI renders.
|
||||
- [ui-plan](../ui-plan/README.md) — the plan-mode surface that sets the `plan-review` intent.
|
||||
- [user-questions](../../interaction/user-questions/README.md) — the Host-side question seam and its answerer waterfall.
|
||||
@@ -112,11 +89,10 @@ No direct invalidation; `dsh-tool-ask-user` owns the model-visible tool call and
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
|
||||
These limits define question-card ownership and cross-client behavior; they are current package constraints.
|
||||
These limits define draft durability and composer ownership; they are current package constraints.
|
||||
|
||||
- **Unsubmitted drafts are local to one browser** — Session navigation, page reload, and browser restart preserve them, but another browser or device reconstructs only the Host-stored question.
|
||||
- **One request owns the question card at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves.
|
||||
- **Questions appear in the active Session composer** — durable pending questions do not add a separate sidebar inbox; reopen the owning Session to answer one.
|
||||
- **Unsubmitted drafts have page-and-Session lifetime** — Session navigation preserves them while that Session scope remains in the page, but a full page reload, Session pruning, or a newly delivered pending-request identity starts with an empty draft. The store never writes them to the Host, `localStorage`, or disk.
|
||||
- **One request owns the composer at a time** — later pending requests remain in the session snapshot and become visible after the earlier request resolves.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
@@ -128,4 +104,4 @@ None.
|
||||
|
||||
</details>
|
||||
|
||||
**Runtime invariant:** The Host Session projection is authoritative for durable timed questions. Browser storage only preserves unfinished input and never creates or keeps a question open. No runtime invariant companion is published because the Host validates and projects the durable state before this client package renders it.
|
||||
**Runtime invariant:** No companion is published. Tool and slot registrations are effects owned and observed by their respective registries; the host pending table is exercised through the public wire protocol.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "dsh Web 客户端的 ask_user_question 功能:附着式提问卡片、计时等待、草稿、迟到回复与 plan-review 审批卡片。"
|
||||
description: "dsh Web 客户端的 ask_user_question 功能:接管编辑器的提问 UI 与 plan-review 审批卡片。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
本包把 agent(智能体)在 Web 客户端中的提问呈现为附着在聊天输入框处的交互卡片。用户可以在问题之间导航、选择选项、输入自定义答案、跳过问题,并提交一批结构化答案。计时问题显示倒计时;未编辑时聚焦会暂停它,首次编辑或「慢慢回答」会让它无限期保持,未触碰的倒计时走完会放行 agent 而卡片仍可回答。关闭卡片只是把面板收起,不构成回答;`ask_user_question` 工具调用行可以重新打开它,问题结束后则以只读方式展示已记录的回答。它可以跨导航、重新加载和机器重启保留。
|
||||
当 agent(智能体)在 Web 客户端中提问时,本包会用交互式提问界面接管聊天编辑器。用户可以在问题之间导航、选择一个或多个选项、输入自定义答案、跳过问题,并提交一批结构化答案。选择单选项后会立即前进,而草稿会在当前页面的生命周期内跨会话导航保留。若唯一的问题声明了受支持的呈现意图,则可使用专用界面,包括带 `Request changes` 和 `Approve` 操作的 plan-review 卡片。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,17 +25,11 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
当 agent 提问时,附着式卡片会暂时占用编辑器位置:回答每个问题、用翻页器导航,或跳过它。选择单选选项后会立即前进;Enter 继续流程,并在所有问题均已回答或跳过后提交,而 Shift+Enter 改为换行(IME 组合输入期间按 Enter 只会确认输入候选,不会前进)。
|
||||
当 agent 提问时,编辑器变成提问界面:回答每个问题、用翻页器导航,或跳过它。选择单选选项后会立即前进;Enter 继续流程,并在所有问题均已回答或跳过后提交,而 Shift+Enter 改为换行(IME 组合输入期间按 Enter 只会确认输入候选,不会前进)。
|
||||
|
||||
### 作答
|
||||
|
||||
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
### 回看已结束的问题
|
||||
|
||||
每条迟到回复只显示一次,包括由它开启新轮次的情况。回复遵循 Chat 原有的过程分组与整轮折叠规则。回复被收起时,展开所属轮次和工作分组即可查看。
|
||||
|
||||
已经结束的问题以只读卡片打开:同一个翻页器走过已记录的回答,并标注「已回答」。所有选项与文本框都被禁用,跳过与提交动作消失,用户当时跳过的问题会在空回答处说明这一点,自由文本框只出现在当时确实用过它的回答上。关闭它会移除该卡片,工具调用行会用同一份记录再建一个。
|
||||
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
### plan-review 卡片
|
||||
|
||||
@@ -43,19 +37,7 @@ kind: "package-reference"
|
||||
|
||||
### 失败与恢复
|
||||
|
||||
回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。
|
||||
|
||||
本地返回回答不会释放前台接手记录:Host 接受 waterfall 结果后才关闭该 stream。插件在这段传输期间继续拥有接手记录。显式委托会在调用下一个回答方前释放它,插件卸载则取消剩余接手记录。
|
||||
|
||||
卡片有两个来源。Host 的存活请求创建它,回答也经该请求返回。计时请求先由 Client 打开问答业务的 `attachWait` stream,再从剩余时长帧推导本地时钟上的 deadline。接手记录持续到请求结算或 Client 断开,收起面板不会释放它。倒计时归卡片所有;到零时以 `ASK_TIMED_OUT` reject,卡片仍可用。投影把调用列为已继续后,回答改用 `answer` Remote 方法。卡片移除遵循投影,插件卸载时释放请求与接手记录。
|
||||
|
||||
经挂起请求的提交只算发出,不算确认:另一个浏览器先结算了同一请求时,gateway 会静默丢弃迟到的结果。因此卡片保留草稿直到投影关闭它;若提交在途时该调用变为已继续,控件带提示重新可用,同一份草稿改走 Remote 路径。未编辑时聚焦会冻结本浏览器的剩余倒计时,失焦后按该余量恢复。首次修改回答会把本地等待改为无限,「慢慢回答」也会显式执行同样操作。这些选择会随本浏览器草稿跨会话导航与重新加载保留。另一浏览器拥有自己的倒计时,仍可结算共享请求;Host projection 继续作为问题处于开放或已继续状态的权威来源。
|
||||
|
||||
每个会话和待处理请求在浏览器存储中都有独立草稿。切换会话、重新加载页面或重启同一个浏览器后,未完成的输入会恢复;收起的面板会保留草稿供下次打开使用,已关闭的卡片会清掉自己的草稿,之后挂载的卡片会修剪没有任何卡片拥有的草稿。草稿不会同步到另一个浏览器或设备,Host 投影的问题会。
|
||||
|
||||
工具调用开放期间重新连接的浏览器会再次收到挂起请求,仍可用剩余时间完成它。在 agent 或进程结束后重新打开的会话把卡片显示为已继续;提交时先恢复会话的根 agent,再把回答作为新的用户轮次送入。
|
||||
|
||||
已结算的问题回复在聊天历史中保持紧凑。气泡以三角标记指示可以展开;点击它会展开只读的问题与回答详情,且不会重新打开已经结算的工具调用,也不会产生第二次提交。它的复制动作写出每个问题与用户当时给出的回答,无论气泡展开还是收起都是同一份文本。
|
||||
通用提问流程把当前题号、已选标签、自定义文本和显式跳过状态保存在非持久化 slot 存储中;该存储归属对应 Session generation,并以待处理请求的本地渲染标识为键。从 Session A 切换到 B 时,如果没有其他引用持有 A,A 就会结束,因此返回 A 时会得到空的问题草稿;如果另一个引用继续持有 A 的 generation,该草稿也会保留。不同的请求标识读取空草稿,并在首次编辑时替换旧值;成功回答或取消会清除相符的值。请求是否仍在等待由主机保持权威。
|
||||
|
||||
-----
|
||||
|
||||
@@ -67,20 +49,16 @@ kind: "package-reference"
|
||||
|
||||
本包是一条归属规则:渲染提问是宿主的 UI 能力,拥有该工具则是 agent 的能力,因此 `tool-ask-user` 行属于需要它的各个 preset(以及没有 preset 的 TUI 组装)。
|
||||
|
||||
### 重新打开面板
|
||||
|
||||
本包填充 `userQuestionPanels`——`dsh-client-ui-tool` 为其 `ask_user_question` 行声明的可选能力。`reveal(sessionId, callId)` 把该调用的卡片重新发布为同优先级中最后一个待处理交互,编辑器位置于是再次显示它;当本客户端没有该调用的卡片时返回 `false`——例如旧版未命名的请求、另一个浏览器的问题,或投影已关闭的卡片。问题是否仍可回答取自 `userQuestions` 会话投影,由该行直接读取,因此这项能力自身不携带状态。
|
||||
|
||||
投影只列出仍可回答的调用,因此 `review(sessionId, callId, record)` 用该行从自己转录中解析出的问题与回答建卡。这张卡片没有回答通道也没有倒计时,投影清扫不会碰它,关闭它是移除而非留在注册表里。仍持有实时卡片的调用显示实时那张,过期副本不会替换正在进行的请求;同理,只读卡片渲染记录本身,只从实时卡片留下的草稿里取页码。
|
||||
|
||||
### 意图界面选择
|
||||
|
||||
卡片接管声明了意图、以 `detail` 携带计划、提供了被指名的批准标签的单个问题,要求除批准外最多一个选项,且非多选。次要操作返回编辑器供用户提出修改要求。更多选项或多选问题仍由通用流程处理。计划审批通过 `conversation.plan-review.actions` 插槽提供请求键、完整正文和可选的调用标识;计划插件从历史中打开已记录计划,并把没有调用标识的审批作为临时侧边栏预览打开,打开文档不会回答或关闭审批。
|
||||
卡片接管声明了意图、以 `detail` 携带计划、提供了被指名的批准标签的单个问题,要求除批准外最多一个选项,且非多选。次要操作返回编辑器供用户提出修改要求。更多选项或多选问题仍由通用流程处理。
|
||||
|
||||
### 文案与 locale
|
||||
|
||||
编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该条目,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
|
||||
|
||||
计划审批通过 `conversation.plan-review.actions` 插槽提供请求键、完整正文和可选的调用标识。计划插件从历史中打开已记录计划,并把没有调用标识的审批作为临时侧边栏预览打开。打开文档不会回答或关闭审批。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -90,8 +68,7 @@ kind: "package-reference"
|
||||
|
||||
以下页面覆盖编辑器宿主、工具 seam 与 plan-mode 消费方。
|
||||
|
||||
- [ui-conversation](../ui-conversation/README.zh.md)——拥有 `conversation.composer` slot 的聊天界面。
|
||||
- [ui-tool](../ui-tool/README.zh.md)——工具调用记录界面,其 `ask_user_question` 行可重新打开被收起的面板。
|
||||
- [ui-conversation](../ui-conversation/README.zh.md)——拥有 `conversation.composer` 链的聊天界面。
|
||||
- [tool-ask-user](../../interaction/tool-ask-user/README.zh.md)——面向模型的工具;本 UI 会渲染其 schema 与答案。
|
||||
- [ui-plan](../ui-plan/README.zh.md)——设置 `plan-review` 意图的 plan-mode 界面。
|
||||
- [user-questions](../../interaction/user-questions/README.zh.md)——Host 侧提问 seam 及其应答方 waterfall(瀑布式事件)。
|
||||
@@ -112,11 +89,10 @@ kind: "package-reference"
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
|
||||
这些限制定义提问卡片归属与跨客户端行为;它们是当前包约束。
|
||||
这些限制定义草稿持久性与编辑器归属;它们是当前包约束。
|
||||
|
||||
- **未提交草稿仅保存在一个浏览器中**:切换会话、重新加载页面和重启浏览器后都会保留,但另一个浏览器或设备只会重建 Host 保存的问题。
|
||||
- **每次只有一个请求拥有提问卡片**:后续待处理请求仍留在会话快照中,并在较早请求落定后显示。
|
||||
- **问题显示在当前会话的编辑器中**:持久待处理问题不会新增独立的侧栏收件箱;请重新打开所属会话进行回答。
|
||||
- **未提交草稿的生命周期限于当前页面与会话**:只要该会话作用域仍留在页面内,会话导航就会保留草稿;完整刷新页面、会话被裁剪,或待处理请求以新的本地标识重新交付时,则从空草稿开始。存储从不把草稿写入主机、`localStorage` 或磁盘。
|
||||
- **每次只有一个请求拥有编辑器**:后续待处理请求仍留在会话快照中,并在较早请求落定后显示。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
@@ -128,4 +104,4 @@ kind: "package-reference"
|
||||
|
||||
</details>
|
||||
|
||||
**运行时不变式:** Host 会话投影是持久计时问题的权威来源。浏览器存储只保留未完成输入,绝不会创建问题或让问题继续保持打开。此包不发布运行时不变量 companion,因为 Host 会先校验并投影持久状态,客户端随后才渲染它。
|
||||
**运行时不变式:** 不发布伴生入口。工具与 slot 注册都是由各自注册表持有和观察的 effect;Host 待处理表通过公开的 wire protocol 测试。
|
||||
@@ -33,8 +33,7 @@
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-renderer",
|
||||
"@deepseek-ai/dsh-client-ui-session",
|
||||
"@deepseek-ai/dsh-client-ui-chat"
|
||||
"@deepseek-ai/dsh-client-ui-session"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -52,7 +51,6 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-brand": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:*",
|
||||
@@ -69,9 +67,7 @@
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"clsx": "^2.0.0",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-chat": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:*"
|
||||
"@deepseek-ai/dsh-llm": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -67,7 +67,7 @@ export function PlanReviewPanel({ pending, review, t, renderSlot }: PlanReviewPa
|
||||
<div className={css.actions}>
|
||||
<Button
|
||||
variant="outline" className={css.discuss} icon={<IconEditOutlineRegular size={14} />}
|
||||
disabled={busy} onClick={() => { settle(() => pending.dismiss()) }}
|
||||
disabled={busy} onClick={() => { settle(() => pending.cancel()) }}
|
||||
>
|
||||
{t('plan.discuss')}
|
||||
</Button>
|
||||
|
||||
@@ -62,17 +62,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.waitButton {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.waitStatus {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Figma 1019:36938 header, user-tuned: heading block left, close right; the
|
||||
pager sits in the footer to balance the card. */
|
||||
.header {
|
||||
@@ -304,16 +293,6 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Read-only note standing in for a question the user skipped: it takes the
|
||||
option rows' horizontal padding so it lines up with the choices above it. */
|
||||
.reviewNote {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* Custom answer row (figma 973:36427): an option-shaped row whose copy is an
|
||||
inline text input; focus or a typed draft lifts it to the selected look. */
|
||||
.customRow {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import {
|
||||
useEffect, useMemo, useRef, useState,
|
||||
type ChangeEvent, type FocusEvent, type KeyboardEvent,
|
||||
} from 'react'
|
||||
import { useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular,
|
||||
IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular,
|
||||
IconEditOutlineRegular, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts'
|
||||
import {
|
||||
planReviewOf,
|
||||
type QuestionAnswer, type QuestionComposerProps,
|
||||
} from './contract/slots.ts'
|
||||
import type { PendingQuestion } from './contract/slots.ts'
|
||||
import type { QuestionDraftAnswer } from './draft-store.ts'
|
||||
import type { QuestionDraftAnswer, QuestionDraftProgress } from './draft-store.ts'
|
||||
import { PlanReviewPanel } from './PlanReviewPanel.tsx'
|
||||
import css from './QuestionComposer.module.css'
|
||||
|
||||
@@ -20,12 +20,7 @@ import css from './QuestionComposer.module.css'
|
||||
* runtime failure messages (finished strings from the wire) pass through
|
||||
* verbatim.
|
||||
*/
|
||||
type Feedback = { key: 'error.incomplete' | 'error.unanswered' | 'error.unavailable' | 'error.resubmit' } | { text: string }
|
||||
|
||||
/** A removed card can remain mounted until the composer seat updates. */
|
||||
const REMOVED_CARD: QuestionCardSnapshot = {
|
||||
state: 'open', waitState: 'counting', countdown: undefined, channel: 'none', closed: true,
|
||||
}
|
||||
type Feedback = { key: 'error.incomplete' | 'error.unanswered' } | { text: string }
|
||||
|
||||
/**
|
||||
* Split the conventional recommendation suffix without changing the answer value.
|
||||
@@ -124,7 +119,6 @@ export function QuestionComposer(props: QuestionComposerProps) {
|
||||
pending={question}
|
||||
t={props.t}
|
||||
useStore={props.useStore}
|
||||
useQuestionCard={props.useQuestionCard}
|
||||
actions={props.actions}
|
||||
/>
|
||||
)
|
||||
@@ -132,74 +126,26 @@ export function QuestionComposer(props: QuestionComposerProps) {
|
||||
}
|
||||
|
||||
type QuestionFlowProps =
|
||||
{ pending: PendingQuestion } & Pick<QuestionComposerProps, 't' | 'useStore' | 'useQuestionCard' | 'actions'>
|
||||
{ pending: PendingQuestion } & Pick<QuestionComposerProps, 't' | 'useStore' | 'actions'>
|
||||
|
||||
function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: QuestionFlowProps) {
|
||||
function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) {
|
||||
const questions = pending.questions
|
||||
// A read-only card built from a settled call's transcript: the same panel
|
||||
// over the recorded answers, with nothing left to submit.
|
||||
const review = pending.review
|
||||
const markdownLabels = useMemo(() => ({
|
||||
code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
|
||||
footnotes: t('markdown.footnotes'),
|
||||
}), [t])
|
||||
const initialDrafts = useMemo<QuestionDraftAnswer[]>(() => {
|
||||
// Recorded answers echo the question ids rather than their order.
|
||||
const recorded = new Map((review ?? []).map(answer => [answer.id, answer]))
|
||||
return questions.map((item) => {
|
||||
const answer = recorded.get(item.id)
|
||||
const custom = answer?.custom ?? ''
|
||||
return {
|
||||
selected: [...answer?.selected ?? []],
|
||||
custom,
|
||||
// A recorded answer with no selection and no custom text was skipped.
|
||||
skipped: answer !== undefined && answer.selected.length === 0 && custom === '',
|
||||
}
|
||||
})
|
||||
}, [questions, review])
|
||||
const stored = useStore(state => state.progressByRequest[pending.key])
|
||||
// A review card renders the record itself, so a draft its live card left
|
||||
// behind can never surface as an answer; only the page position is restored.
|
||||
const storedProgress = review === undefined ? stored : undefined
|
||||
const index = stored?.index ?? 0
|
||||
const drafts = storedProgress?.drafts ?? initialDrafts
|
||||
const restoredWait = storedProgress?.wait
|
||||
?? (storedProgress?.drafts.some(item => item.selected.length > 0 || item.custom !== '' || item.skipped) === true
|
||||
? 'editing' : undefined)
|
||||
const initialProgress = useMemo<QuestionDraftProgress>(() => ({
|
||||
index: 0,
|
||||
drafts: questions.map(() => ({ selected: [], custom: '', skipped: false })),
|
||||
}), [questions])
|
||||
const storedProgress = useStore(state => (
|
||||
state.requestKey === pending.key && state.progress.drafts.length === questions.length
|
||||
? state.progress
|
||||
: undefined
|
||||
))
|
||||
const { index, drafts } = storedProgress ?? initialProgress
|
||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||
const [error, setError] = useState<Feedback | null>(null)
|
||||
const card = useQuestionCard(pending.key, snapshot => snapshot ?? REMOVED_CARD)
|
||||
const canSubmit = card.channel !== 'none'
|
||||
// The answer surface freezes while a submission is in flight, and for good on
|
||||
// a review card: its call has already settled.
|
||||
const locked = busy !== null || review !== undefined
|
||||
// A waterfall submission is only "sent": the gateway drops an outcome that
|
||||
// arrives after another Client settled the event, without telling anyone.
|
||||
// The draft therefore survives until the projection closes the card, and a
|
||||
// card that flips to continued while a submission is in flight re-arms the
|
||||
// controls so the same draft can go through the Remote path.
|
||||
const sentVia = useRef<'waterfall' | 'rpc' | null>(null)
|
||||
useEffect(() => {
|
||||
if (sentVia.current !== 'waterfall' || card.state !== 'continued') return
|
||||
sentVia.current = null
|
||||
setBusy(null)
|
||||
setError({ key: 'error.resubmit' })
|
||||
}, [card.state])
|
||||
useEffect(() => {
|
||||
if (card.closed) actions.clear(pending.key)
|
||||
}, [actions, card.closed, pending.key])
|
||||
useEffect(() => {
|
||||
actions.prune(pending.liveKeys())
|
||||
}, [actions, pending])
|
||||
const waitDisposition = useRef<'editing' | 'waiting' | undefined>(restoredWait)
|
||||
useEffect(() => {
|
||||
if (waitDisposition.current === 'waiting') pending.takeTime()
|
||||
if (waitDisposition.current === 'editing') pending.engage()
|
||||
}, [pending])
|
||||
// The countdown belongs to the carrier, not to this component: the user can
|
||||
// close the panel and reopen it from the tool call row, and a timer that died
|
||||
// with the mount would leave the tool call waiting past its deadline.
|
||||
const countdown = card.countdown
|
||||
// Collapsed to the header strip so the conversation above stays readable
|
||||
// while the user decides; answer drafts live in the Session store above.
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
@@ -207,7 +153,6 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
// collapsed question must not steal focus from the expand toggle back into
|
||||
// the input, so focus is granted once per question index.
|
||||
const focusedQuestions = useRef(new Set<number>())
|
||||
const answerSurface = useRef<HTMLDivElement>(null)
|
||||
// Every navigation write stays in bounds and drafts mirrors questions 1:1.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const question = questions[index]!
|
||||
@@ -216,71 +161,15 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
const hasOptions = (question.options?.length ?? 0) > 0
|
||||
|
||||
const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => {
|
||||
actions.replace(pending.key, {
|
||||
index: nextIndex,
|
||||
drafts: nextDrafts,
|
||||
...(waitDisposition.current === undefined ? {} : { wait: waitDisposition.current }),
|
||||
})
|
||||
actions.replace(pending.key, { index: nextIndex, drafts: nextDrafts })
|
||||
}
|
||||
|
||||
const takeTime = (): void => {
|
||||
waitDisposition.current = 'waiting'
|
||||
pending.takeTime()
|
||||
replaceProgress(index, drafts)
|
||||
}
|
||||
|
||||
const engage = (): void => {
|
||||
if (waitDisposition.current !== undefined) return
|
||||
waitDisposition.current = 'editing'
|
||||
pending.engage()
|
||||
}
|
||||
|
||||
const focusAnswerSurface = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) pending.holdFocus()
|
||||
}
|
||||
|
||||
const blurAnswerSurface = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) pending.releaseFocus()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const release = (): void => { pending.releaseFocus() }
|
||||
const refocus = (): void => {
|
||||
if (waitDisposition.current === 'editing') {
|
||||
pending.engage()
|
||||
return
|
||||
}
|
||||
if (answerSurface.current?.contains(document.activeElement) === true) pending.holdFocus()
|
||||
}
|
||||
const visibilityChanged = (): void => { if (document.hidden) release(); else refocus() }
|
||||
window.addEventListener('blur', release)
|
||||
window.addEventListener('focus', refocus)
|
||||
document.addEventListener('visibilitychange', visibilityChanged)
|
||||
return () => {
|
||||
release()
|
||||
window.removeEventListener('blur', release)
|
||||
window.removeEventListener('focus', refocus)
|
||||
document.removeEventListener('visibilitychange', visibilityChanged)
|
||||
}
|
||||
}, [pending])
|
||||
|
||||
const dismissFlow = (): void => {
|
||||
// A tool-call-keyed panel only leaves the seat; nothing is sent and nothing
|
||||
// is persisted, and the tool call row brings it back.
|
||||
if (pending.dismissal === 'hide') {
|
||||
void pending.dismiss()
|
||||
return
|
||||
}
|
||||
if (!canSubmit) {
|
||||
setError({ key: 'error.unavailable' })
|
||||
return
|
||||
}
|
||||
const cancelFlow = (): void => {
|
||||
setBusy('cancel')
|
||||
setError(null)
|
||||
sentVia.current = 'waterfall'
|
||||
void pending.dismiss()
|
||||
void pending.cancel()
|
||||
.then(() => { actions.clear(pending.key) })
|
||||
.catch((cause: unknown) => {
|
||||
sentVia.current = null
|
||||
setBusy(null)
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
@@ -290,7 +179,6 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
update: (current: QuestionDraftAnswer) => QuestionDraftAnswer,
|
||||
nextIndex = index,
|
||||
): void => {
|
||||
engage()
|
||||
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index ? update(item) : item)
|
||||
replaceProgress(nextIndex, nextDrafts)
|
||||
setError(null)
|
||||
@@ -320,10 +208,6 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
setError({ key: 'error.incomplete' })
|
||||
return
|
||||
}
|
||||
if (!canSubmit) {
|
||||
setError({ key: 'error.unavailable' })
|
||||
return
|
||||
}
|
||||
const answer: QuestionAnswer = {
|
||||
answers: questions.map((item, itemIndex) => {
|
||||
const value = values[itemIndex] as QuestionDraftAnswer
|
||||
@@ -338,10 +222,9 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
}
|
||||
setBusy('answer')
|
||||
setError(null)
|
||||
sentVia.current = card.channel === 'waterfall' ? 'waterfall' : 'rpc'
|
||||
void pending.answer(answer)
|
||||
.then(() => { actions.clear(pending.key) })
|
||||
.catch((cause: unknown) => {
|
||||
sentVia.current = null
|
||||
setBusy(null)
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
@@ -380,7 +263,6 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
}
|
||||
|
||||
const skipQuestion = (): void => {
|
||||
engage()
|
||||
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
|
||||
? { selected: [], custom: '', skipped: true }
|
||||
: item)
|
||||
@@ -406,25 +288,6 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
</h2>
|
||||
</div>
|
||||
<div className={css.headerActions}>
|
||||
{countdown !== undefined && card.waitState !== 'waiting' && card.waitState !== 'editing' && card.waitState !== 'continued' && (
|
||||
<span className={css.waitStatus}>
|
||||
{t(countdown.running ? 'wait.countdown' : 'wait.paused', {
|
||||
seconds: Math.ceil(countdown.remainingMs / 1000),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{countdown !== undefined && card.waitState !== 'waiting' && card.waitState !== 'editing' && card.waitState !== 'continued' && (
|
||||
<Button variant="outline" className={css.waitButton} onClick={takeTime}>
|
||||
{t('wait.takeTime')}
|
||||
</Button>
|
||||
)}
|
||||
{card.state === 'continued' && <span className={css.waitStatus}>{t('wait.continued')}</span>}
|
||||
{/* A held or frozen countdown says so; a request that never carried
|
||||
one waits silently, as the blocking question always has. */}
|
||||
{countdown !== undefined && (card.waitState === 'waiting' || card.waitState === 'editing') && (
|
||||
<span className={css.waitStatus}>{t('wait.held')}</span>
|
||||
)}
|
||||
{review !== undefined && <span className={css.waitStatus}>{t('review.status')}</span>}
|
||||
<button
|
||||
type="button" className={css.iconButton}
|
||||
aria-label={t(minimized ? 'nav.maximize' : 'nav.minimize')}
|
||||
@@ -436,10 +299,9 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
{minimized ? <IconChevronUpOutlineRegular /> : <IconChevronDownOutlineRegular />}
|
||||
</button>
|
||||
<button
|
||||
type="button" className={css.iconButton}
|
||||
aria-label={t(pending.dismissal === 'hide' ? 'nav.close' : 'nav.cancel')}
|
||||
title={t(pending.dismissal === 'hide' ? 'nav.close' : 'nav.cancel')}
|
||||
disabled={busy !== null} onClick={dismissFlow}
|
||||
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
|
||||
title={t('nav.cancel')}
|
||||
disabled={busy !== null} onClick={cancelFlow}
|
||||
>
|
||||
<IconCloseOutlineRegular />
|
||||
</button>
|
||||
@@ -448,13 +310,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
|
||||
{!minimized && (
|
||||
<>
|
||||
<div
|
||||
ref={answerSurface}
|
||||
className={css.body}
|
||||
data-question-scroll
|
||||
onFocusCapture={focusAnswerSurface}
|
||||
onBlurCapture={blurAnswerSurface}
|
||||
>
|
||||
<div className={css.body} data-question-scroll>
|
||||
{question.detail !== undefined && (
|
||||
<div className={css.detail}><MarkdownText text={question.detail} labels={markdownLabels} /></div>
|
||||
)}
|
||||
@@ -469,7 +325,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
role={question.multiSelect === true ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-label={display.label}
|
||||
disabled={locked}
|
||||
disabled={busy !== null}
|
||||
onClick={() => { choose(option.label) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' || !drafts.every(completed)) return
|
||||
@@ -499,14 +355,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
)
|
||||
})}
|
||||
|
||||
{review !== undefined && draft.skipped && (
|
||||
<p className={css.reviewNote}>{t('review.skipped')}</p>
|
||||
)}
|
||||
|
||||
{/* A review card keeps the free-text field only when the
|
||||
recorded answer used it; an empty disabled box with a
|
||||
placeholder would read as somewhere to type. */}
|
||||
{(review === undefined || draft.custom !== '') && (hasOptions
|
||||
{hasOptions
|
||||
? (
|
||||
<div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
|
||||
{question.multiSelect === true
|
||||
@@ -526,7 +375,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
<AnswerField
|
||||
variant="inline"
|
||||
value={draft.custom}
|
||||
disabled={locked}
|
||||
disabled={busy !== null}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={draftCustom}
|
||||
onKeyDown={continueFromCustom}
|
||||
@@ -535,18 +384,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
)
|
||||
: (
|
||||
<AnswerField
|
||||
// A missing countdown does not imply an indefinite request:
|
||||
// a timed request can still be awaiting its claim's first frame.
|
||||
autoFocus={canSubmit && countdown === undefined && !locked && !focusedQuestions.current.has(index)}
|
||||
autoFocus={!focusedQuestions.current.has(index)}
|
||||
variant="block"
|
||||
value={draft.custom}
|
||||
disabled={locked}
|
||||
disabled={busy !== null}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onFocus={() => { focusedQuestions.current.add(index) }}
|
||||
onChange={draftCustom}
|
||||
onKeyDown={continueFromCustom}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -571,23 +418,19 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
<div className={css.feedback} role="status">
|
||||
{error === null ? null : 'key' in error ? t(error.key) : error.text}
|
||||
</div>
|
||||
{/* A review card has nothing to send: the pager alone walks the record. */}
|
||||
{review === undefined && (
|
||||
<div className={css.footerActions}>
|
||||
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
|
||||
{t('action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy !== null || !answered(draft) || (index === questions.length - 1 && !canSubmit)}
|
||||
onClick={continueFlow}
|
||||
>
|
||||
{busy === 'answer'
|
||||
? t('submitting')
|
||||
: index === questions.length - 1 ? t('submit') : t('action.next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className={css.footerActions}>
|
||||
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
|
||||
{t('action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
|
||||
>
|
||||
{busy === 'answer'
|
||||
? t('submitting')
|
||||
: index === questions.length - 1 ? t('submit') : t('action.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 72%;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--dsw-radius-xl);
|
||||
background: var(--dsw-specific-bubble);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
font-size: var(--dsh-content-font-size, 14px);
|
||||
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bubble:has(.toggle:focus-visible) {
|
||||
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
.labelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.caret {
|
||||
display: inline-flex;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.caret svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
display: block;
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: var(--dsh-content-font-size, 14px);
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin: 8px 0 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--dsw-alias-label-secondary) 24%, transparent);
|
||||
}
|
||||
|
||||
.details > div + div {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.details dt {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.question {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.header,
|
||||
.detail,
|
||||
.options {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.questionText {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.options {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.details dd {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.answer {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.answerLabel {
|
||||
margin-right: 4px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
padding-right: 4px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.time {
|
||||
padding-right: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular,
|
||||
IconCopyOutlineRegular, Tooltip, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { QuestionReplyData } from './question-reply.ts'
|
||||
import { replyAnswerValues, replyClipboardText } from './question-reply.ts'
|
||||
import css from './QuestionReplyView.module.css'
|
||||
|
||||
type QuestionReplyViewProps =
|
||||
PropsRuntime<'conversation.chat.node', 'question-reply'>
|
||||
& PropsLocale<'question'>
|
||||
|
||||
/**
|
||||
* Right-aligned late-reply bubble: a label naming the earlier pending
|
||||
* questions, then one question and answer pair per question, or the bare
|
||||
* questions when the user dismissed them. A payload the Client cannot read
|
||||
* falls back to the model-facing text.
|
||||
*/
|
||||
export const QuestionReplyView = memo(function QuestionReplyView({ node, t }: QuestionReplyViewProps) {
|
||||
return <QuestionReplyBubble data={node.data} t={t} />
|
||||
})
|
||||
|
||||
/**
|
||||
* Render one settled question reply as a compact transcript bubble.
|
||||
* @param props - Reply data and the question locale translator.
|
||||
* @returns The expandable reply bubble.
|
||||
*/
|
||||
export function QuestionReplyBubble({ data, t }: { data: QuestionReplyData; t: PropsLocale<'question'>['t'] }) {
|
||||
const dismissed = data.outcome === 'dismissed'
|
||||
const label = t(dismissed ? 'reply.dismissedLabel' : 'reply.label')
|
||||
const [open, setOpen] = useState(false)
|
||||
const toggleLabel = t(open ? 'reply.close' : 'reply.open')
|
||||
const summary = data.questions.map(question => replyAnswerValues(data, question.id)).flat().join(', ')
|
||||
const copyText = replyClipboardText(data, t)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
void writeClipboard(copyText).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => { setCopied(false) }, 1000)
|
||||
})
|
||||
}, [copied, copyText])
|
||||
return (
|
||||
<div className={css.row} data-question-reply={data.callId} data-reply-outcome={data.outcome} role="group" aria-label={label}>
|
||||
<div className={css.bubble}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggle}
|
||||
aria-expanded={open}
|
||||
aria-label={`${label} · ${toggleLabel}`}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<span className={css.labelRow}>
|
||||
{/* The caret is the only sign the bubble opens; the label alone
|
||||
reads as plain text. Its state is on the button already. */}
|
||||
<span className={css.caret} aria-hidden="true">
|
||||
{open ? <IconChevronDownOutlineRegular /> : <IconChevronRightOutlineRegular />}
|
||||
</span>
|
||||
<span className={css.label}>{label}</span>
|
||||
</span>
|
||||
{!open && <span className={css.summary}>{dismissed || summary === '' ? t('reply.skipped') : summary}</span>}
|
||||
</button>
|
||||
{open && (data.questions.length === 0
|
||||
? <p className={css.text}>{data.text}</p>
|
||||
: (
|
||||
<dl className={css.details}>
|
||||
{data.questions.map((question) => {
|
||||
const values = replyAnswerValues(data, question.id)
|
||||
return (
|
||||
<div key={question.id}>
|
||||
<dt className={css.question}>
|
||||
{question.header && question.header !== question.question && <span className={css.header}>{question.header}</span>}
|
||||
<span className={css.questionText}>{question.question}</span>
|
||||
{question.detail && <span className={css.detail}>{question.detail}</span>}
|
||||
{question.options && question.options.length > 0 && (
|
||||
<ul className={css.options}>
|
||||
{question.options.map(option => <li key={option.label}>{option.label}{option.description && ` — ${option.description}`}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</dt>
|
||||
<dd className={css.answer}>
|
||||
<span className={css.answerLabel}>{t('reply.answerLabel')}</span>
|
||||
{dismissed ? t('reply.skipped') : values.length === 0 ? t('reply.skipped') : values.join(', ')}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
))}
|
||||
</div>
|
||||
<div className={css.actions}>
|
||||
<span className={css.time}>
|
||||
{new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }).format(data.time)}
|
||||
</span>
|
||||
<Tooltip label={copied ? t('copied') : t('copy')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={copied ? t('copied') : t('copy')} onClick={onCopy}>
|
||||
{copied ? <IconCheckOutlineRegular /> : <IconCopyOutlineRegular />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
/** Question composer props and one pending Remote waterfall response. */
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// The client module declares the conversation.composer SlotMap entry required by PropsRuntime.
|
||||
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, UserQuestionState,
|
||||
} from '@deepseek-ai/dsh-user-questions/types'
|
||||
AskUserQuestionAnswer, AskUserQuestionItem,
|
||||
} from '@deepseek-ai/dsh-user-questions'
|
||||
import type { createQuestionDraftStore } from '../draft-store.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-session/client' {
|
||||
@@ -102,452 +102,113 @@ export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | u
|
||||
|
||||
let nextQuestionKey = 0
|
||||
|
||||
/** Rejection codes a Client returns through the waterfall; the wire preserves `name` and `code`. */
|
||||
export type QuestionRejectionCode = 'ASK_ABORTED' | 'ASK_CANCELLED' | 'ASK_TIMED_OUT'
|
||||
|
||||
const rejectionMessages: Record<QuestionRejectionCode, string> = {
|
||||
ASK_ABORTED: 'ask_user_question was aborted before the user answered',
|
||||
ASK_CANCELLED: 'the user cancelled ask_user_question',
|
||||
ASK_TIMED_OUT: 'ask_user_question timed out before the user answered',
|
||||
}
|
||||
|
||||
/** Create a wire-preserved user-question rejection. */
|
||||
function questionError(code: QuestionRejectionCode): Error {
|
||||
const error = new Error(rejectionMessages[code]) as Error & { code: string }
|
||||
function questionError(message: string, code: 'ASK_ABORTED' | 'ASK_CANCELLED'): Error {
|
||||
const error = new Error(message) as Error & { code: string }
|
||||
error.name = 'UserQuestionError'
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
/** One live Host waterfall attached to a card; it settles once and then detaches. */
|
||||
export interface QuestionWaterfallChannel {
|
||||
/** Client-clock deadline in epoch milliseconds; absent for a blocking question. */
|
||||
readonly deadline: number | undefined
|
||||
/** Resolve the Host waterfall with the whole answer batch. */
|
||||
resolve(answer: QuestionAnswer): void
|
||||
/** Reject the Host waterfall with a wire-preserved code. */
|
||||
reject(code: Exclude<QuestionRejectionCode, 'ASK_ABORTED'>): void
|
||||
/** Hand the request to the next waterfall listener. */
|
||||
delegate(): void
|
||||
}
|
||||
|
||||
/** The Remote answer path of a continued question. */
|
||||
export interface QuestionRpcChannel {
|
||||
/** Steer the answer into the agent; false when the question is no longer continued. */
|
||||
answer(answer: QuestionAnswer): Promise<boolean>
|
||||
}
|
||||
|
||||
/** The composer seat a card occupies while it is visible. */
|
||||
export interface QuestionSeat {
|
||||
/** Withdraw the card from the pending-interaction registry without ending the request. */
|
||||
hide(): void
|
||||
}
|
||||
|
||||
/** One waterfall request as the Remote Event listener awaits it. */
|
||||
export interface QuestionWaterfallRequest {
|
||||
readonly channel: QuestionWaterfallChannel
|
||||
/** Outcome returned to the Host: the answer, or a wire-preserved rejection. */
|
||||
readonly result: Promise<QuestionAnswer>
|
||||
/**
|
||||
* Test whether a rejection asks the listener to call `next()`.
|
||||
* @param reason - rejection received from {@link QuestionWaterfallRequest.result}.
|
||||
* @returns whether {@link QuestionWaterfallChannel.delegate} produced it.
|
||||
*/
|
||||
isDelegation(reason: unknown): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the deferred one Remote Event listener settles through a card.
|
||||
* The request signal ends the channel with `ASK_ABORTED`; the Host ignores that
|
||||
* outcome for an event it already finished, so a cancel frame loses nothing.
|
||||
* @param deadline - Client-clock deadline in epoch milliseconds derived from the business claim's remaining duration.
|
||||
* @param signal - Delivery lifetime of the forwarded request.
|
||||
* @param onSettle - Called once with the channel when it settles or aborts.
|
||||
* @returns The channel to attach and the promise the listener awaits.
|
||||
*/
|
||||
export function createWaterfallRequest(
|
||||
deadline: number | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
onSettle: (channel: QuestionWaterfallChannel) => void,
|
||||
): QuestionWaterfallRequest {
|
||||
const completion = Promise.withResolvers<QuestionAnswer>()
|
||||
const delegated = Symbol('pending question delegated')
|
||||
let settled = false
|
||||
const finish = (settle: () => void): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
settle()
|
||||
onSettle(channel)
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
finish(() => { completion.reject(questionError('ASK_ABORTED')) })
|
||||
}
|
||||
const channel: QuestionWaterfallChannel = {
|
||||
deadline,
|
||||
resolve: (answer) => { finish(() => { completion.resolve(answer) }) },
|
||||
reject: (code) => { finish(() => { completion.reject(questionError(code)) }) },
|
||||
delegate: () => { finish(() => { completion.reject(delegated) }) },
|
||||
}
|
||||
if (signal !== undefined) {
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) onAbort()
|
||||
}
|
||||
return { channel, result: completion.promise, isDelegation: reason => reason === delegated }
|
||||
}
|
||||
|
||||
/** Reactive card state read by every mounted presentation of one question. */
|
||||
export interface QuestionCardSnapshot {
|
||||
/** Mirrors the projection row; `open` until the projection says otherwise. */
|
||||
readonly state: UserQuestionState
|
||||
/** User-visible foreground wait presentation. */
|
||||
readonly waitState: 'counting' | 'focused' | 'editing' | 'waiting' | 'continued'
|
||||
/**
|
||||
* Foreground countdown presentation, absent for a request that carried no
|
||||
* deadline. The card owns the ticking, so a hidden panel keeps counting and
|
||||
* still times out; `running` is false while a hold or an edit froze it.
|
||||
*/
|
||||
readonly countdown: { readonly remainingMs: number; readonly running: boolean } | undefined
|
||||
/** Channel a submission would use right now. */
|
||||
readonly channel: 'waterfall' | 'rpc' | 'none'
|
||||
/** Set once the card left the registry; the mounted composer clears its draft on this. */
|
||||
readonly closed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One answerable Client card. A card keyed by tool call is created by whichever
|
||||
* source arrives first, the forwarded waterfall or the Session projection, and
|
||||
* removed only when the projection no longer lists the call.
|
||||
*/
|
||||
/** One answerable Client presentation of a pending Host waterfall. */
|
||||
export class PendingQuestion {
|
||||
/** Presentation discriminator used by Session pending-interaction consumers. */
|
||||
readonly kind: 'question' | 'plan-review'
|
||||
/** Render identity and request key for the Session-scoped draft store. */
|
||||
/** Opaque render identity and request key for the Session-scoped draft store. */
|
||||
readonly key: string
|
||||
/** Agent/Session identity owning the request. */
|
||||
readonly sessionId: SessionId
|
||||
/** The request's question list. */
|
||||
readonly questions: readonly AskUserQuestionItem[]
|
||||
/** Tool call identity; absent for a blocking request that carried no `wait`. */
|
||||
readonly callId: ToolCallId | undefined
|
||||
/**
|
||||
* Recorded answers of a call that already settled. Present only on a
|
||||
* read-only review card, which the tool call row builds from its own
|
||||
* transcript so a finished question can be read back in the panel that
|
||||
* asked it. Such a card has no answer channel and no countdown.
|
||||
*/
|
||||
readonly review: readonly AskUserQuestionAnswerItem[] | undefined
|
||||
/**
|
||||
* What closing the panel does. A card keyed by tool call stays reachable
|
||||
* from its tool call row, so closing only withdraws the panel (`hide`) and
|
||||
* persists nothing. A card the Host never named has no way back, so closing
|
||||
* it ends the request (`cancel`).
|
||||
*/
|
||||
readonly dismissal: 'hide' | 'cancel'
|
||||
/** Result returned by the Remote Event listener to the Host waterfall. */
|
||||
readonly result: Promise<QuestionAnswer>
|
||||
|
||||
#state: UserQuestionState = 'open'
|
||||
#waterfall: QuestionWaterfallChannel | undefined
|
||||
#rpc: QuestionRpcChannel | undefined
|
||||
#seat: QuestionSeat | undefined
|
||||
#timedWait = false
|
||||
#timer: ReturnType<typeof setInterval> | undefined
|
||||
#deadline: number | undefined
|
||||
#remainingMs: number | undefined
|
||||
#focused = false
|
||||
#engaged = false
|
||||
#held = false
|
||||
#closed = false
|
||||
readonly #siblings: (() => readonly string[]) | undefined
|
||||
readonly #listeners = new Set<() => void>()
|
||||
#snapshot: QuestionCardSnapshot
|
||||
readonly #resolve: (answer: QuestionAnswer) => void
|
||||
readonly #reject: (reason: unknown) => void
|
||||
readonly #signal: AbortSignal | undefined
|
||||
readonly #onAbort: (() => void) | undefined
|
||||
readonly #delegated = Symbol('pending question delegated')
|
||||
#settled = false
|
||||
|
||||
/**
|
||||
* @param sessionId - Agent/Session identity owning the request.
|
||||
* @param sessionId - Agent/Session identity owning the scoped request.
|
||||
* @param questions - complete question batch.
|
||||
* @param callId - tool call identity when the Host named one.
|
||||
* @param siblings - keys of every card currently registered for the Session, for draft pruning.
|
||||
* @param review - recorded answers of a settled call, making this a read-only card.
|
||||
* @param signal - Host request and delivery lifetime.
|
||||
*/
|
||||
constructor(
|
||||
sessionId: SessionId,
|
||||
readonly sessionId: SessionId,
|
||||
questions: readonly AskUserQuestionItem[],
|
||||
callId?: ToolCallId,
|
||||
siblings?: () => readonly string[],
|
||||
review?: readonly AskUserQuestionAnswerItem[],
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
this.sessionId = sessionId
|
||||
nextQuestionKey += 1
|
||||
this.key = `question:${String(nextQuestionKey)}`
|
||||
this.questions = questions
|
||||
this.kind = planReviewOf(questions) === undefined ? 'question' : 'plan-review'
|
||||
this.callId = callId
|
||||
this.review = review
|
||||
this.dismissal = callId === undefined ? 'cancel' : 'hide'
|
||||
this.#siblings = siblings
|
||||
nextQuestionKey += 1
|
||||
this.key = callId === undefined
|
||||
? `question:${String(nextQuestionKey)}`
|
||||
: PendingQuestion.keyOf(sessionId, callId)
|
||||
this.#snapshot = this.createSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Card key of a tool call, shared by every source that names one. The Host
|
||||
* request and the Session projection carry the branded `ToolCallId`; a
|
||||
* transcript row carries the same wire value as a plain string.
|
||||
* @param sessionId - owning Session.
|
||||
* @param callId - tool call identity, branded or as a transcript spells it.
|
||||
* @returns the render identity and draft key.
|
||||
*/
|
||||
static keyOf(sessionId: SessionId, callId: string): string {
|
||||
return `question:${String(sessionId)}:${callId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft keys that are still live in this Session, this card included.
|
||||
* @returns keys the draft store must keep; everything else is stale.
|
||||
*/
|
||||
liveKeys(): readonly string[] {
|
||||
return this.#siblings?.() ?? [this.key]
|
||||
}
|
||||
|
||||
/** Subscribe to card state changes. */
|
||||
readonly subscribe = (listener: () => void): (() => void) => {
|
||||
this.#listeners.add(listener)
|
||||
return () => { this.#listeners.delete(listener) }
|
||||
}
|
||||
|
||||
/** Read the stable current card state. */
|
||||
readonly snapshot = (): QuestionCardSnapshot => this.#snapshot
|
||||
|
||||
/** Observable snapshot read by the renderer's keyed Hook. */
|
||||
readonly getSnapshot = this.snapshot
|
||||
|
||||
private createSnapshot(): QuestionCardSnapshot {
|
||||
const channel = this.#waterfall !== undefined
|
||||
? 'waterfall'
|
||||
: this.#state === 'continued' && this.#rpc !== undefined ? 'rpc' : 'none'
|
||||
const waitState = this.#state === 'continued'
|
||||
? 'continued'
|
||||
: this.#held ? 'waiting'
|
||||
: this.#engaged ? 'editing'
|
||||
: this.#focused ? 'focused' : 'counting'
|
||||
return {
|
||||
state: this.#state,
|
||||
waitState,
|
||||
countdown: this.#timedWait
|
||||
? {
|
||||
remainingMs: this.#deadline === undefined
|
||||
? this.#remainingMs ?? 0
|
||||
: Math.max(0, this.#deadline - Date.now()),
|
||||
running: this.#deadline !== undefined,
|
||||
}
|
||||
: undefined,
|
||||
channel,
|
||||
closed: this.#closed,
|
||||
}
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
this.#snapshot = this.createSnapshot()
|
||||
this.#reschedule()
|
||||
for (const listener of this.#listeners) listener()
|
||||
}
|
||||
|
||||
/**
|
||||
* Own the countdown here rather than in a mounted component: the panel can be
|
||||
* hidden and remounted while the request stands, and a timer that died with
|
||||
* the component would leave the tool call waiting past its deadline.
|
||||
*/
|
||||
#reschedule(): void {
|
||||
const wanted = this.#deadline !== undefined && !this.#closed
|
||||
if (wanted === (this.#timer !== undefined)) return
|
||||
if (!wanted) {
|
||||
clearInterval(this.#timer)
|
||||
this.#timer = undefined
|
||||
const completion = Promise.withResolvers<QuestionAnswer>()
|
||||
this.result = completion.promise
|
||||
this.#resolve = completion.resolve
|
||||
this.#reject = completion.reject
|
||||
this.#signal = signal
|
||||
if (signal === undefined) {
|
||||
this.#onAbort = undefined
|
||||
return
|
||||
}
|
||||
this.#timer = setInterval(() => { this.#tick() }, 1000)
|
||||
}
|
||||
|
||||
#tick(): void {
|
||||
const deadline = this.#deadline
|
||||
/* v8 ignore next -- #reschedule clears the interval in the same publish that drops the deadline. */
|
||||
if (deadline === undefined) return
|
||||
if (Date.now() >= deadline) {
|
||||
this.timeout()
|
||||
return
|
||||
const onAbort = (): void => {
|
||||
this.abort(questionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
|
||||
}
|
||||
this.publish()
|
||||
this.#onAbort = onAbort
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) onAbort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the live waterfall of a forwarded request.
|
||||
* @param channel - request channel created by {@link createWaterfallRequest}.
|
||||
*/
|
||||
attachWaterfall(channel: QuestionWaterfallChannel): void {
|
||||
this.#waterfall = channel
|
||||
this.#timedWait = channel.deadline !== undefined
|
||||
if (!this.#held && !this.#engaged) {
|
||||
this.#deadline = channel.deadline
|
||||
if (this.#focused && channel.deadline !== undefined) {
|
||||
this.#remainingMs = Math.max(0, channel.deadline - Date.now())
|
||||
this.#deadline = undefined
|
||||
}
|
||||
}
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a waterfall channel that settled or was cancelled; the card stays.
|
||||
* @param channel - the channel that ended.
|
||||
*/
|
||||
detachWaterfall(channel: QuestionWaterfallChannel): void {
|
||||
if (this.#waterfall !== channel) return
|
||||
this.#waterfall = undefined
|
||||
this.#timedWait = false
|
||||
this.#deadline = undefined
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a live waterfall is attached.
|
||||
* @returns whether the pending Host request still accepts settlement.
|
||||
*/
|
||||
hasWaterfall(): boolean {
|
||||
return this.#waterfall !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the Remote answer path used once the question is continued.
|
||||
* @param channel - Remote calls bound to this Session and call.
|
||||
*/
|
||||
attachRpc(channel: QuestionRpcChannel): void {
|
||||
this.#rpc = channel
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the composer seat this card is published into.
|
||||
* @param seat - withdrawal of the published panel, owned by the card registry.
|
||||
*/
|
||||
attachSeat(seat: QuestionSeat): void {
|
||||
this.#seat = seat
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the projection row state.
|
||||
* @param state - `open` or `continued`.
|
||||
*/
|
||||
setState(state: UserQuestionState): void {
|
||||
if (this.#state === state) return
|
||||
this.#state = state
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/** Stop this Client's countdown indefinitely; the waterfall then waits like a blocking question. */
|
||||
takeTime(): void {
|
||||
if (this.#held) return
|
||||
this.#held = true
|
||||
this.#focused = false
|
||||
this.#remainingMs = undefined
|
||||
this.#deadline = undefined
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Record focus even before the request arrives, freezing a pristine countdown once attached.
|
||||
* @param now - current Client epoch time.
|
||||
*/
|
||||
holdFocus(now = Date.now()): void {
|
||||
if (this.#held || this.#engaged || this.#focused) return
|
||||
this.#remainingMs = this.#deadline === undefined ? undefined : Math.max(0, this.#deadline - now)
|
||||
this.#deadline = undefined
|
||||
this.#focused = true
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a pristine countdown after the answer surface loses focus.
|
||||
* @param now - current Client epoch time.
|
||||
*/
|
||||
releaseFocus(now = Date.now()): void {
|
||||
if (!this.#focused) return
|
||||
this.#focused = false
|
||||
this.#deadline = this.#timedWait && this.#remainingMs !== undefined ? now + this.#remainingMs : undefined
|
||||
this.#remainingMs = undefined
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the first edited draft answerable without a foreground deadline.
|
||||
* @param now - current Client epoch time used to preserve the remaining duration.
|
||||
*/
|
||||
engage(now = Date.now()): void {
|
||||
if (this.#held || this.#engaged) return
|
||||
if (this.#remainingMs === undefined && this.#deadline !== undefined) {
|
||||
this.#remainingMs = Math.max(0, this.#deadline - now)
|
||||
}
|
||||
this.#engaged = true
|
||||
this.#focused = false
|
||||
this.#deadline = undefined
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/** Local countdown reached zero: settle the waterfall with `ASK_TIMED_OUT`, keep the card. */
|
||||
timeout(): void {
|
||||
if (this.#held || this.#engaged || this.#focused) return
|
||||
const channel = this.#waterfall
|
||||
if (channel === undefined) return
|
||||
this.#waterfall = undefined
|
||||
this.#timedWait = false
|
||||
this.#deadline = undefined
|
||||
channel.reject('ASK_TIMED_OUT')
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/** Hand a live waterfall to the next listener when this presentation domain unloads. */
|
||||
delegate(): void {
|
||||
this.#waterfall?.delegate()
|
||||
}
|
||||
|
||||
/** Mark the card removed from the registry; the mounted composer clears its draft. */
|
||||
close(): void {
|
||||
if (this.#closed) return
|
||||
this.#closed = true
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the whole answer batch through the live waterfall, or through the
|
||||
* Remote path once the question is continued.
|
||||
* Resolve the Host waterfall with the whole answer batch.
|
||||
* @param answer - complete structured answer batch.
|
||||
*/
|
||||
answer(answer: QuestionAnswer): Promise<void> {
|
||||
const waterfall = this.#waterfall
|
||||
if (waterfall !== undefined) {
|
||||
return settlePendingComposer(() => { waterfall.resolve(answer) }, 'pending question settlement failed')
|
||||
}
|
||||
const rpc = this.#rpc
|
||||
if (this.#state === 'continued' && rpc !== undefined) {
|
||||
return rpc.answer(answer).then((accepted) => {
|
||||
if (!accepted) throw new Error('this question is no longer answerable')
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error('no channel accepts an answer yet'))
|
||||
return settlePendingComposer(() => {
|
||||
this.finish(() => { this.#resolve(answer) })
|
||||
}, 'pending question settlement failed')
|
||||
}
|
||||
|
||||
/** Delegate an unanswered request to the next waterfall listener. */
|
||||
delegate(): void {
|
||||
if (this.#settled) return
|
||||
this.finish(() => { this.#reject(this.#delegated) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the panel. A tool-call-keyed card only leaves the composer seat: the
|
||||
* request stands, the countdown keeps running here, and the tool call row
|
||||
* reopens it. A card the Host never named ends its request instead, because
|
||||
* nothing could bring it back.
|
||||
* Test whether a rejection requests waterfall delegation.
|
||||
* @param reason - rejection received from {@link PendingQuestion.result}.
|
||||
* @returns whether {@link PendingQuestion.delegate} produced it.
|
||||
*/
|
||||
dismiss(): Promise<void> {
|
||||
if (this.dismissal === 'hide') {
|
||||
return settlePendingComposer(() => { this.#seat?.hide() }, 'pending question hide failed')
|
||||
isDelegation(reason: unknown): boolean {
|
||||
return reason === this.#delegated
|
||||
}
|
||||
|
||||
/** Reject the Host waterfall because the user closed the question. */
|
||||
cancel(): Promise<void> {
|
||||
return settlePendingComposer(() => {
|
||||
this.finish(() => {
|
||||
this.#reject(questionError('the user cancelled ask_user_question', 'ASK_CANCELLED'))
|
||||
})
|
||||
}, 'pending question cancellation failed')
|
||||
}
|
||||
|
||||
/**
|
||||
* End an unanswered presentation when its transport, scope, or plugin lifetime ends.
|
||||
* @param reason - rejection exposed to the waiting Remote Event listener.
|
||||
*/
|
||||
abort(reason: unknown): void {
|
||||
if (this.#settled) return
|
||||
this.finish(() => { this.#reject(reason) })
|
||||
}
|
||||
|
||||
private finish(settle: () => void): void {
|
||||
if (this.#settled) throw new Error(`pending question ${this.key} is already settled`)
|
||||
this.#settled = true
|
||||
if (this.#signal !== undefined && this.#onAbort !== undefined) {
|
||||
this.#signal.removeEventListener('abort', this.#onAbort)
|
||||
}
|
||||
const waterfall = this.#waterfall
|
||||
if (waterfall === undefined) return Promise.reject(new Error('no channel accepts a cancellation yet'))
|
||||
return settlePendingComposer(() => { waterfall.reject('ASK_CANCELLED') }, 'pending question cancellation failed')
|
||||
settle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,6 +226,5 @@ export type QuestionComposerProps =
|
||||
PropsRuntime<'conversation.composer'>
|
||||
& PropsStore<ReturnType<typeof createQuestionDraftStore>>
|
||||
& PropsRenderSlots<'conversation.plan-review.actions'>
|
||||
& InjectFace<{ keyedHooks: { questionCard: (key: string) => PendingQuestion | undefined } }>
|
||||
& { matched: QuestionWait }
|
||||
& PropsLocale<'question'>
|
||||
@@ -21,43 +21,36 @@ export interface QuestionDraftProgress {
|
||||
index: number
|
||||
/** One draft per question, in request order. */
|
||||
drafts: QuestionDraftAnswer[]
|
||||
/** Local indefinite-wait choice restored with the draft. */
|
||||
wait?: 'editing' | 'waiting'
|
||||
}
|
||||
|
||||
interface QuestionDraftState {
|
||||
progressByRequest: Record<string, QuestionDraftProgress>
|
||||
requestKey?: string
|
||||
progress: QuestionDraftProgress
|
||||
}
|
||||
|
||||
type QuestionDraftActions = {
|
||||
replace: (draft: QuestionDraftState, requestKey: string, progress: QuestionDraftProgress) => void
|
||||
clear: (draft: QuestionDraftState, requestKey: string) => void
|
||||
prune: (draft: QuestionDraftState, keep: readonly string[]) => void
|
||||
}
|
||||
|
||||
const emptyProgress = (): QuestionDraftProgress => ({ index: 0, drafts: [] })
|
||||
|
||||
/**
|
||||
* Declare the question composer's Session store. Drafts persist per Session so
|
||||
* leaving the Session or restarting the Client does not erase an unfinished answer.
|
||||
* @returns a persisted store handle whose instance is owned by the Slot registry.
|
||||
* Declare the question composer's transient Session store.
|
||||
* @returns a non-persisted store handle whose instance is owned by the Slot registry.
|
||||
*/
|
||||
export function createQuestionDraftStore(): EngineStoreHandle<QuestionDraftState, QuestionDraftActions> {
|
||||
return defineStore({
|
||||
init: (): QuestionDraftState => ({ progressByRequest: {} }),
|
||||
persist: 'dsh.user-questions.drafts.v1',
|
||||
init: (): QuestionDraftState => ({ progress: emptyProgress() }),
|
||||
actions: {
|
||||
replace: (draft, requestKey, progress) => {
|
||||
draft.progressByRequest[requestKey] = progress
|
||||
draft.requestKey = requestKey
|
||||
draft.progress = progress
|
||||
},
|
||||
clear: (draft, requestKey) => {
|
||||
draft.progressByRequest = Object.fromEntries(
|
||||
Object.entries(draft.progressByRequest).filter(([key]) => key !== requestKey),
|
||||
)
|
||||
},
|
||||
prune: (draft, keep) => {
|
||||
const live = new Set(keep)
|
||||
draft.progressByRequest = Object.fromEntries(
|
||||
Object.entries(draft.progressByRequest).filter(([key]) => live.has(key)),
|
||||
)
|
||||
if (draft.requestKey !== requestKey) return
|
||||
delete draft.requestKey
|
||||
draft.progress = emptyProgress()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,29 +6,25 @@
|
||||
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
|
||||
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
|
||||
*
|
||||
* One entry renders every pending user-question request as the generic
|
||||
* question flow. Plan review has its own carrier and composer in ui-plan, so
|
||||
* the two presentation domains cannot race the same seat.
|
||||
* One entry, two shapes: the composer renders a request that declares a
|
||||
* presentation intent as that intent's own surface (`plan-review` → the plan
|
||||
* decision card) and every other request as the generic question flow. A
|
||||
* separate chain entry per shape would race the same carrier, so the shape
|
||||
* choice lives inside this entry — see QuestionComposer.
|
||||
*/
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { PendingInteractionPublisher } from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { AskUserQuestionItem, PendingUserQuestion, UserQuestionProjectionView } from '@deepseek-ai/dsh-user-questions/types'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { UserQuestionPanels, UserQuestionRecord } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import { createWaterfallRequest, PendingQuestion, type QuestionRpcChannel } from './contract/slots.ts'
|
||||
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { PendingQuestion } from './contract/slots.ts'
|
||||
import { createQuestionDraftStore } from './draft-store.ts'
|
||||
import { QuestionComposer } from './QuestionComposer.tsx'
|
||||
import { questionReplyDefinition } from './question-reply.ts'
|
||||
import { QuestionReplyView } from './QuestionReplyView.tsx'
|
||||
import { en, zh, type QuestionKey } from './locales.ts'
|
||||
|
||||
export type {
|
||||
@@ -51,300 +47,42 @@ type ClientQuestionRequest = Parameters<QuestionListener>[0]
|
||||
type ClientQuestionNext = Parameters<QuestionListener>[1]
|
||||
type ClientQuestionAnswer = Awaited<ReturnType<QuestionListener>>
|
||||
|
||||
/** Required services: Agent scopes, Remote Events, Session UI, Slot registry, conversation nodes, and copy. */
|
||||
export const inject = ['sessions', 'remote', 'remote.userQuestions', 'uiSession', 'slots', 'locale', 'uiConversation']
|
||||
/** Required services: Agent scopes, Remote Events, Session UI, Slot registry, and copy. */
|
||||
export const inject = ['sessions', 'remote', 'uiSession', 'slots', 'locale']
|
||||
|
||||
/** One card and the composer seat it moves in and out of. */
|
||||
interface QuestionCard {
|
||||
readonly pending: PendingQuestion
|
||||
/** Withdraw the panel from the composer seat; the request and its countdown stand. */
|
||||
readonly hide: () => void
|
||||
/** Publish the panel last, so it wins the seat against any other pending question. */
|
||||
readonly reveal: () => void
|
||||
/** Drop the card for good: the projection no longer lists its call. */
|
||||
readonly remove: () => void
|
||||
/**
|
||||
* Record the completion of the listener awaiting this card's waterfall, so a
|
||||
* plugin teardown that delegates the request resolves only once that
|
||||
* listener has handed it to `next()`.
|
||||
*/
|
||||
readonly awaitListener: (completion: Promise<void>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards published to the Session pending-interaction registry, keyed by
|
||||
* `PendingQuestion.key`. A tool-call-keyed card is shared by the forwarded
|
||||
* waterfall and the Session projection.
|
||||
*
|
||||
* A card outlives its seat. Closing the panel only unpublishes it, which keeps
|
||||
* the request answerable from its tool call row, so this registry — not the
|
||||
* pending-interaction registry — decides when a request is over.
|
||||
*/
|
||||
class QuestionCards {
|
||||
readonly #cards = new Map<string, QuestionCard>()
|
||||
|
||||
constructor(private readonly publish: PendingInteractionPublisher<PendingQuestion>) {}
|
||||
|
||||
byCallId(sessionId: SessionId, callId: string): QuestionCard | undefined {
|
||||
return this.#cards.get(PendingQuestion.keyOf(sessionId, callId))
|
||||
}
|
||||
|
||||
/** Observable state for one card while the registry owns it. */
|
||||
source(key: string): PendingQuestion | undefined {
|
||||
return this.#cards.get(key)?.pending
|
||||
}
|
||||
|
||||
values(): readonly QuestionCard[] {
|
||||
return [...this.#cards.values()]
|
||||
}
|
||||
|
||||
/** Keys of every card registered for one Session. */
|
||||
keysFor(sessionId: SessionId): readonly string[] {
|
||||
return this.values().filter(card => card.pending.sessionId === sessionId).map(card => card.pending.key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the card of a tool call, creating and publishing it when absent.
|
||||
* A request without a call id always gets a fresh card.
|
||||
*/
|
||||
ensure(sessionId: SessionId, questions: readonly AskUserQuestionItem[], callId: ToolCallId | undefined): QuestionCard {
|
||||
if (callId !== undefined) {
|
||||
const existing = this.byCallId(sessionId, callId)
|
||||
if (existing !== undefined) return existing
|
||||
}
|
||||
return this.#create(new PendingQuestion(sessionId, questions, callId, () => this.keysFor(sessionId)))
|
||||
}
|
||||
|
||||
/** Publish one carrier into the composer seat and register the card that owns it. */
|
||||
#create(pending: PendingQuestion): QuestionCard {
|
||||
let listener: Promise<void> | undefined
|
||||
const delegate = async (): Promise<void> => {
|
||||
pending.delegate()
|
||||
await listener
|
||||
}
|
||||
// Republishing is how a revealed card reaches the seat: the registry keeps
|
||||
// the last entry of equal precedence, and it rejects a duplicate key.
|
||||
let unpublish: (() => void) | undefined = this.publish(pending, delegate)
|
||||
const card: QuestionCard = {
|
||||
pending,
|
||||
hide: () => {
|
||||
unpublish?.()
|
||||
unpublish = undefined
|
||||
},
|
||||
reveal: () => {
|
||||
unpublish?.()
|
||||
unpublish = this.publish(pending, delegate)
|
||||
},
|
||||
remove: () => {
|
||||
/* v8 ignore next -- a card leaves the registry once; no caller holds a card the registry already replaced. */
|
||||
if (this.#cards.get(pending.key) !== card) return
|
||||
this.#cards.delete(pending.key)
|
||||
pending.close()
|
||||
unpublish?.()
|
||||
unpublish = undefined
|
||||
},
|
||||
awaitListener: (completion) => { listener = completion },
|
||||
}
|
||||
// A review card has no request left to park: closing it drops the card, and
|
||||
// the tool call row builds another one from the same transcript record.
|
||||
pending.attachSeat({ hide: pending.review === undefined ? card.hide : card.remove })
|
||||
this.#cards.set(pending.key, card)
|
||||
return card
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the panel of one answerable tool call.
|
||||
* @param sessionId - Session the tool call belongs to.
|
||||
* @param callId - `ask_user_question` call whose panel to show, as its
|
||||
* transcript row spells it.
|
||||
* @returns whether a card for that call is still answerable.
|
||||
*/
|
||||
reveal(sessionId: SessionId, callId: string): boolean {
|
||||
const card = this.byCallId(sessionId, callId)
|
||||
if (card === undefined) return false
|
||||
card.reveal()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one settled tool call's recorded answers as a read-only card. A call
|
||||
* that somehow still holds a card is shown as it stands, so a live request is
|
||||
* never replaced by a stale copy of itself.
|
||||
* @param sessionId - Session the tool call belongs to.
|
||||
* @param callId - `ask_user_question` call whose record to show, as its
|
||||
* transcript row spells it.
|
||||
* @param record - the call's questions and recorded answers.
|
||||
* @returns true; a record always produces a card.
|
||||
*/
|
||||
review(sessionId: SessionId, callId: string, record: UserQuestionRecord): boolean {
|
||||
const card = this.byCallId(sessionId, callId) ?? this.#create(new PendingQuestion(
|
||||
sessionId,
|
||||
record.questions,
|
||||
brandString<ToolCallId>(callId),
|
||||
() => this.keysFor(sessionId),
|
||||
record.answers,
|
||||
))
|
||||
card.reveal()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand every live waterfall back at plugin teardown. The pending-interaction
|
||||
* registry only drains what it currently holds, and a hidden card is not in
|
||||
* it, so its Host request would wait for its own abort instead.
|
||||
*/
|
||||
dispose(): void {
|
||||
for (const card of this.values()) {
|
||||
card.pending.delegate()
|
||||
card.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Present one forwarded request through its card until the waterfall settles. */
|
||||
/** Present one request until the user answers, cancels, or its lifetime ends. */
|
||||
async function answerQuestion(
|
||||
ctx: ClientContext,
|
||||
owner: ClientContext,
|
||||
request: ClientQuestionRequest,
|
||||
next: ClientQuestionNext,
|
||||
cards: QuestionCards,
|
||||
registerPendingInteraction: PendingInteractionPublisher<PendingQuestion>,
|
||||
): Promise<ClientQuestionAnswer> {
|
||||
const sessionId = ctx.sessions.scopeOf(owner)
|
||||
const sessionId = (ctx.sessions as ISessions).scopeOf(owner)
|
||||
if (sessionId === undefined) return next()
|
||||
const callId = request.wait?.callId
|
||||
const card = cards.ensure(sessionId, request.questions, callId)
|
||||
const claimLifetime = new AbortController()
|
||||
const claimSignal = request.signal === undefined ? claimLifetime.signal
|
||||
: AbortSignal.any([claimLifetime.signal, request.signal])
|
||||
let claim: ReturnType<typeof ctx.remote.userQuestions.attachWait> | undefined
|
||||
let claimEnded: Promise<never> | undefined
|
||||
let delegateRequest: (() => void) | undefined
|
||||
const releaseClaim = request.wait?.timed === true && callId !== undefined
|
||||
? ctx.effect(() => {
|
||||
claim = ctx.remote.userQuestions.attachWait(sessionId, callId, claimSignal)
|
||||
return async () => {
|
||||
delegateRequest?.()
|
||||
claimLifetime.abort()
|
||||
claim?.dispose()
|
||||
if (claimEnded !== undefined) await Promise.allSettled([claimEnded])
|
||||
}
|
||||
}, 'ui-user-questions: foreground claim')
|
||||
: undefined
|
||||
const pending = new PendingQuestion(sessionId, request.questions, request.signal)
|
||||
const completed = Promise.withResolvers<void>()
|
||||
card.awaitListener(completed.promise)
|
||||
const remove = registerPendingInteraction(pending, async () => {
|
||||
pending.delegate()
|
||||
await completed.promise
|
||||
})
|
||||
try {
|
||||
const iterator = claim?.[Symbol.asyncIterator]()
|
||||
const opening = iterator === undefined ? undefined : await iterator.next()
|
||||
if (opening?.done === true) return await next()
|
||||
const waterfall = createWaterfallRequest(
|
||||
opening === undefined ? undefined : Date.now() + opening.value.remainingMs,
|
||||
claimSignal,
|
||||
(channel) => { card.pending.detachWaterfall(channel) },
|
||||
)
|
||||
delegateRequest = () => { waterfall.channel.delegate() }
|
||||
card.pending.attachWaterfall(waterfall.channel)
|
||||
if (iterator !== undefined) {
|
||||
claimEnded = (async () => {
|
||||
await iterator.next()
|
||||
throw new Error('the foreground question wait ended')
|
||||
})()
|
||||
}
|
||||
try {
|
||||
return await (claimEnded === undefined ? waterfall.result : Promise.race([waterfall.result, claimEnded]))
|
||||
return await pending.result
|
||||
} catch (error) {
|
||||
if (waterfall.isDelegation(error)) {
|
||||
await releaseClaim?.()
|
||||
return await next()
|
||||
}
|
||||
if (pending.isDelegation(error)) return await next()
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
if (releaseClaim === undefined) claimLifetime.abort()
|
||||
else if (claimEnded === undefined) await releaseClaim()
|
||||
else {
|
||||
// The Host closes the claim after accepting the waterfall outcome.
|
||||
// A local return precedes transmission and cannot release that claim.
|
||||
void Promise.allSettled([claimEnded]).then(() => { void releaseClaim() })
|
||||
}
|
||||
// A blocking request without a call id is not in the projection; its card ends with its waterfall.
|
||||
if (callId === undefined) card.remove()
|
||||
remove()
|
||||
completed.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the `userQuestions` projection of every bound Session onto the cards:
|
||||
* continued rows get a card and the Remote answer path, and a tool-call-keyed
|
||||
* card whose call the projection no longer lists is removed once its waterfall is gone.
|
||||
*/
|
||||
function publishContinuedQuestions(ctx: ClientContext, cards: QuestionCards): () => void {
|
||||
const sessions = ctx.sessions
|
||||
const stopProjections = new Map<SessionId, () => void>()
|
||||
|
||||
const unwrap = <T>(result: { ok: true; value: T } | { ok: false; error: { message: string } }): T => {
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
return result.value
|
||||
}
|
||||
const rpcFor = (sessionId: SessionId, callId: ToolCallId): QuestionRpcChannel => ({
|
||||
answer: async answer => unwrap(await ctx.remote.userQuestions.answer(sessionId, callId, answer)),
|
||||
})
|
||||
|
||||
const reconcile = (): void => {
|
||||
const snapshot = sessions.list.getSnapshot()
|
||||
const bound = new Map(Object.values(snapshot.byId).flatMap((summary) => {
|
||||
const binding = sessions.binding(summary.id)
|
||||
return binding === undefined ? [] : [[summary.id, binding] as const]
|
||||
}))
|
||||
for (const [sessionId, stop] of stopProjections) {
|
||||
if (bound.has(sessionId)) continue
|
||||
stop()
|
||||
stopProjections.delete(sessionId)
|
||||
}
|
||||
for (const [sessionId, binding] of bound) {
|
||||
if (stopProjections.has(sessionId)) continue
|
||||
stopProjections.set(sessionId, binding.session.projections.faceOf('userQuestions').subscribe(reconcile))
|
||||
}
|
||||
const rows = new Map<string, { sessionId: SessionId; row: PendingUserQuestion }>()
|
||||
for (const [sessionId, binding] of bound) {
|
||||
const projected = binding.session.projections.faceOf('userQuestions').getSnapshot() as
|
||||
UserQuestionProjectionView | undefined
|
||||
for (const row of projected?.active ?? []) {
|
||||
rows.set(PendingQuestion.keyOf(sessionId, row.callId), { sessionId, row })
|
||||
}
|
||||
}
|
||||
for (const { sessionId, row } of rows.values()) {
|
||||
if (row.state === 'continued') {
|
||||
const card = cards.ensure(sessionId, row.questions, row.callId)
|
||||
card.pending.attachRpc(rpcFor(sessionId, row.callId))
|
||||
card.pending.setState('continued')
|
||||
continue
|
||||
}
|
||||
cards.byCallId(sessionId, row.callId)?.pending.setState('open')
|
||||
}
|
||||
for (const card of cards.values()) {
|
||||
// A review card's call already settled, so the projection no longer lists it as answerable.
|
||||
if (card.pending.callId === undefined
|
||||
|| card.pending.review !== undefined
|
||||
|| rows.has(card.pending.key)
|
||||
|| card.pending.hasWaterfall()) continue
|
||||
card.remove()
|
||||
}
|
||||
}
|
||||
|
||||
reconcile()
|
||||
const stopList = sessions.list.subscribe(reconcile)
|
||||
return () => {
|
||||
stopList()
|
||||
for (const stop of stopProjections.values()) stop()
|
||||
stopProjections.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body: register the `question` dictionaries, the question
|
||||
* composer into the composer chain, and the late-reply conversation node.
|
||||
* Zero business face — data and verbs live on the matched carrier; t rides
|
||||
* the standard locale seat.
|
||||
* Client plugin body: register the `question` dictionaries and the question
|
||||
* composer into the composer chain. Zero business face — data and verbs live
|
||||
* on the matched carrier; t rides the standard locale seat.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -353,14 +91,6 @@ export function apply(ctx: ClientContext): void {
|
||||
const registerPendingInteraction = ctx.uiSession.registerPendingInteraction<PendingQuestion>(
|
||||
pending => pending.kind === 'plan-review' ? 2 : 1,
|
||||
)
|
||||
const cards = new QuestionCards(registerPendingInteraction)
|
||||
ctx.effect(() => () => { cards.dispose() }, 'ui-user-questions: cards')
|
||||
const disposePanels = ctx.reflect.provide('userQuestionPanels', {
|
||||
reveal: (sessionId, callId) => cards.reveal(sessionId, callId),
|
||||
review: (sessionId, callId, record) => cards.review(sessionId, callId, record),
|
||||
} satisfies UserQuestionPanels)
|
||||
ctx.effect(() => disposePanels, 'ui-user-questions: answer panels')
|
||||
ctx.effect(() => publishContinuedQuestions(ctx, cards), 'ui-user-questions: continued questions')
|
||||
ctx.slots.inject('conversation.composer', () => ctx.slots.register(
|
||||
{
|
||||
name: 'conversation.composer',
|
||||
@@ -368,18 +98,11 @@ export function apply(ctx: ClientContext): void {
|
||||
pendingInteraction instanceof PendingQuestion ? pendingInteraction : null,
|
||||
locale: NS,
|
||||
store: questionDraftStore,
|
||||
inject: () => ({ keyedHooks: { questionCard: (key: string) => cards.source(key) } }),
|
||||
children: { 'conversation.plan-review.actions': { kind: 'list', scope: 'session' } },
|
||||
},
|
||||
QuestionComposer,
|
||||
))
|
||||
ctx.uiConversation.events.register(questionReplyDefinition)
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'question-reply',
|
||||
locale: NS,
|
||||
}, QuestionReplyView))
|
||||
ctx.remote.$on('user-questions/request', function (request, next) {
|
||||
return answerQuestion(ctx, this, request, next, cards)
|
||||
return answerQuestion(ctx, this, request, next, registerPendingInteraction)
|
||||
})
|
||||
}
|
||||
@@ -4,27 +4,11 @@
|
||||
export const zh = {
|
||||
'error.incomplete': '请先完成这道问题。',
|
||||
'error.unanswered': '请选择一个选项或填写自定义答案。',
|
||||
'error.unavailable': '当前无法提交,请稍候再试。',
|
||||
'error.resubmit': '回答未送达,工作已继续,请再提交一次。',
|
||||
'wait.takeTime': '慢慢回答',
|
||||
'wait.countdown': '{seconds} 秒后继续工作',
|
||||
'wait.paused': '已暂停 · 剩余 {seconds} 秒',
|
||||
'wait.held': '会一直等你回答',
|
||||
'wait.continued': '已继续工作,仍可回答',
|
||||
'review.status': '已回答',
|
||||
'review.skipped': '这道问题当时被跳过。',
|
||||
'reply.label': '回答先前等待中的问题',
|
||||
'reply.dismissedLabel': '放弃回答先前等待中的问题',
|
||||
'reply.open': '展开问题详情',
|
||||
'reply.close': '收起问题详情',
|
||||
'reply.answerLabel': '回答:',
|
||||
'reply.skipped': '已跳过',
|
||||
'nav.prev': '上一题',
|
||||
'nav.next': '下一题',
|
||||
'nav.minimize': '收起问题卡片',
|
||||
'nav.maximize': '展开问题卡片',
|
||||
'nav.cancel': '放弃整组问题',
|
||||
'nav.close': '收起问题面板,可从工具调用重新打开',
|
||||
'option.recommended': '推荐',
|
||||
'custom.placeholder': '输入你的答案',
|
||||
'action.skip': '跳过',
|
||||
@@ -42,27 +26,11 @@ export type QuestionKey = keyof typeof zh
|
||||
export const en = {
|
||||
'error.incomplete': 'Please complete this question first.',
|
||||
'error.unanswered': 'Please select an option or enter a custom answer.',
|
||||
'error.unavailable': 'Cannot submit right now; try again in a moment.',
|
||||
'error.resubmit': 'The answer did not arrive before work continued; submit it again.',
|
||||
'wait.takeTime': 'Take time',
|
||||
'wait.countdown': 'Continuing in {seconds}s',
|
||||
'wait.paused': 'Paused · {seconds}s remaining',
|
||||
'wait.held': 'Waiting until you answer',
|
||||
'wait.continued': 'Work continued — you can still answer',
|
||||
'review.status': 'Answered',
|
||||
'review.skipped': 'This question was skipped.',
|
||||
'reply.label': 'Reply to earlier pending questions',
|
||||
'reply.dismissedLabel': 'Dismissed earlier pending questions',
|
||||
'reply.open': 'Open question details',
|
||||
'reply.close': 'Close question details',
|
||||
'reply.answerLabel': 'Answer: ',
|
||||
'reply.skipped': 'Skipped',
|
||||
'nav.prev': 'Previous question',
|
||||
'nav.next': 'Next question',
|
||||
'nav.minimize': 'Collapse the question card',
|
||||
'nav.maximize': 'Expand the question card',
|
||||
'nav.cancel': 'Dismiss all questions',
|
||||
'nav.close': 'Close the panel — reopen it from the tool call',
|
||||
'option.recommended': 'Recommended',
|
||||
'custom.placeholder': 'Type your answer',
|
||||
'action.skip': 'Skip',
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
/** Late-reply conversation node: the steered `user-question-reply` message projected as question and answer pairs. */
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-user-questions/types'
|
||||
|
||||
/** One asked question as echoed in the reply payload. */
|
||||
export interface QuestionReplyQuestion {
|
||||
readonly id: string
|
||||
readonly question: string
|
||||
readonly detail?: string
|
||||
readonly header?: string
|
||||
readonly options?: readonly { readonly label: string; readonly description?: string }[]
|
||||
readonly multiSelect?: boolean
|
||||
}
|
||||
|
||||
/** One structured answer as echoed in the reply payload. */
|
||||
export interface QuestionReplyAnswer {
|
||||
readonly id: string
|
||||
readonly selected: readonly string[]
|
||||
readonly custom?: string
|
||||
}
|
||||
|
||||
/** Presentation data of one late reply. */
|
||||
export interface QuestionReplyData {
|
||||
readonly callId: string
|
||||
/** Whether the user answered the pending questions or dismissed them. */
|
||||
readonly outcome: 'answered' | 'dismissed'
|
||||
readonly questions: readonly QuestionReplyQuestion[]
|
||||
readonly answers: readonly QuestionReplyAnswer[]
|
||||
/** Model-facing text, shown when the payload is unreadable. */
|
||||
readonly text: string
|
||||
readonly time: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-chat/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Late answer to a continued question. */
|
||||
'question-reply': QuestionReplyData
|
||||
}
|
||||
}
|
||||
|
||||
interface QuestionReplyState extends QuestionReplyData {
|
||||
readonly seq: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the question and answer pairs out of the reply text at the conversation boundary.
|
||||
* @param text - Model-facing JSON payload of the steered message.
|
||||
* @returns The pairs, or empty lists when the payload is unreadable.
|
||||
*/
|
||||
export function replyPairsOf(text: string): Pick<QuestionReplyData, 'questions' | 'answers'> {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(text)
|
||||
} catch (error) {
|
||||
// A reply whose text is not JSON renders as its raw text.
|
||||
void error
|
||||
return { questions: [], answers: [] }
|
||||
}
|
||||
if (!isRecord(value) || !Array.isArray(value.questions)) {
|
||||
return { questions: [], answers: [] }
|
||||
}
|
||||
const questions: QuestionReplyQuestion[] = []
|
||||
for (const question of value.questions) {
|
||||
if (!isRecord(question) || typeof question.id !== 'string' || typeof question.question !== 'string') continue
|
||||
questions.push({
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
...(typeof question.detail === 'string' ? { detail: question.detail } : {}),
|
||||
...(typeof question.header === 'string' ? { header: question.header } : {}),
|
||||
...(Array.isArray(question.options)
|
||||
? { options: question.options.flatMap((option) => {
|
||||
if (!isRecord(option) || typeof option.label !== 'string') return []
|
||||
return [{ label: option.label, ...(typeof option.description === 'string' ? { description: option.description } : {}) }]
|
||||
}) }
|
||||
: {}),
|
||||
...(typeof question.multiSelect === 'boolean' ? { multiSelect: question.multiSelect } : {}),
|
||||
})
|
||||
}
|
||||
const answers: QuestionReplyAnswer[] = []
|
||||
for (const answer of Array.isArray(value.answers) ? value.answers : []) {
|
||||
if (!isRecord(answer) || typeof answer.id !== 'string' || !Array.isArray(answer.selected)) continue
|
||||
answers.push({
|
||||
id: answer.id,
|
||||
selected: answer.selected.filter((item): item is string => typeof item === 'string'),
|
||||
...(typeof answer.custom === 'string' ? { custom: answer.custom } : {}),
|
||||
})
|
||||
}
|
||||
return { questions, answers }
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer values of one question in display order: the selected option labels
|
||||
* followed by a non-blank custom answer.
|
||||
* @param data - Projected reply holding the recorded answers.
|
||||
* @param id - Question id to read.
|
||||
* @returns The values, empty when the user skipped that question.
|
||||
*/
|
||||
export function replyAnswerValues(data: QuestionReplyData, id: string): string[] {
|
||||
const answer = data.answers.find(item => item.id === id)
|
||||
const custom = answer?.custom?.trim() ?? ''
|
||||
return [...(answer?.selected ?? []), ...(custom === '' ? [] : [custom])]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clipboard text of one late reply: every question with the answer the user
|
||||
* gave, in the layout the open bubble shows, without the options nobody chose.
|
||||
* The text does not depend on whether the bubble is open.
|
||||
* @param data - Projected reply to copy.
|
||||
* @param t - Bound `question` namespace translator owning the answer labels.
|
||||
* @returns One block per question, or the model-facing text when the payload was unreadable.
|
||||
*/
|
||||
export function replyClipboardText(data: QuestionReplyData, t: PropsLocale<'question'>['t']): string {
|
||||
if (data.questions.length === 0) return data.text
|
||||
const dismissed = data.outcome === 'dismissed'
|
||||
return data.questions.map((question) => {
|
||||
const values = replyAnswerValues(data, question.id)
|
||||
const heading = question.header && question.header !== question.question
|
||||
? `${question.header} — ${question.question}`
|
||||
: question.question
|
||||
const answer = dismissed || values.length === 0
|
||||
? t('reply.skipped')
|
||||
: `${t('reply.answerLabel')}${values.join(', ')}`
|
||||
return `${heading}\n${answer}`
|
||||
}).join('\n\n')
|
||||
}
|
||||
|
||||
/** The durable source of a late reply, read without depending on the merged source union. */
|
||||
function replySource(
|
||||
event: Pick<SessionEvent<'user/message'>, 'data'>,
|
||||
): { callId: string; outcome: QuestionReplyData['outcome'] } | null {
|
||||
const source = event.data.source as { kind?: unknown; callId?: unknown; outcome?: unknown }
|
||||
return source.kind === 'user-question-reply' && typeof source.callId === 'string'
|
||||
? { callId: source.callId, outcome: source.outcome === 'dismissed' ? 'dismissed' : 'answered' }
|
||||
: null
|
||||
}
|
||||
|
||||
/** Late-reply projection owned by the questions UI. */
|
||||
export const questionReplyDefinition: ConversationNodeDefinition<QuestionReplyState> = {
|
||||
kind: 'user-question-reply',
|
||||
target: 'chat',
|
||||
match: (event) => {
|
||||
if (event.type !== 'user/message' || !isAppendSurfaceEvent(event)) return null
|
||||
return replySource(event) === null ? null : { id: String(event.data.id), role: 'start' }
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'user/message') throw new Error('user-question-reply start requires user/message')
|
||||
const event = match.event
|
||||
const block = event.data.content.find(item => item.type === 'text')
|
||||
const text = block?.text ?? ''
|
||||
const source = replySource(event)
|
||||
return {
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
callId: source?.callId ?? '',
|
||||
outcome: source?.outcome ?? 'answered',
|
||||
text,
|
||||
...replyPairsOf(text),
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: (context) => {
|
||||
if (context.state === undefined) return null
|
||||
const { seq, ...data } = context.state
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'question-reply',
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq: seq,
|
||||
location: context.start?.location ?? { kind: 'unresolved' },
|
||||
visibility: 'visible',
|
||||
data,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { ComponentProps } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { QuestionReplyBubble, QuestionReplyView } from '../src/client/QuestionReplyView.tsx'
|
||||
import type { QuestionReplyData } from '../src/client/question-reply.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** Render the keyed Chat renderer itself; the framework props it never reads are stubbed. */
|
||||
function renderView(data: QuestionReplyData) {
|
||||
const props: Partial<ComponentProps<typeof QuestionReplyView>> = {
|
||||
node: {
|
||||
key: 'reply:1', id: 'msg-1', kind: 'question-reply', target: 'chat', anchorSeq: 7,
|
||||
location: { kind: 'session' }, visibility: 'visible', data,
|
||||
},
|
||||
openFile: vi.fn(),
|
||||
openSkill: vi.fn(),
|
||||
renderMessageImages: () => null,
|
||||
inspectCall: vi.fn(),
|
||||
forkAt: vi.fn(),
|
||||
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
|
||||
fileMentions: vi.fn(),
|
||||
useDisclosure,
|
||||
useTurnData: () => undefined,
|
||||
t,
|
||||
}
|
||||
return render(<QuestionReplyView {...props as ComponentProps<typeof QuestionReplyView>} />)
|
||||
}
|
||||
|
||||
const t = makeTranslate(zh)
|
||||
const data = {
|
||||
callId: 'call-reply',
|
||||
outcome: 'answered',
|
||||
questions: [{
|
||||
id: 'goal', question: 'What should we build?', header: 'What should we build?', detail: 'Choose the first milestone.',
|
||||
options: [{ label: 'A dashboard', description: 'A focused web surface.' }, { label: 'A CLI' }],
|
||||
}],
|
||||
answers: [{ id: 'goal', selected: ['A dashboard'] }],
|
||||
text: '{"questions":[],"answers":[]}',
|
||||
time: 1,
|
||||
} satisfies QuestionReplyData
|
||||
describe('QuestionReplyView', () => {
|
||||
it('reopens the settled question details from the history bubble', () => {
|
||||
render(<QuestionReplyBubble data={data} t={t} />)
|
||||
|
||||
const bubble = screen.getByRole('button', { name: /展开问题详情/ })
|
||||
expect(bubble.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getAllByText('A dashboard')).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: 'copy' })).toBeTruthy()
|
||||
expect(screen.getByText(/\d{2}:\d{2}/)).toBeTruthy()
|
||||
expect(screen.queryByText('What should we build?')).toBeNull()
|
||||
|
||||
fireEvent.click(bubble)
|
||||
|
||||
expect(screen.getByRole('button', { name: /收起问题详情/ }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText('What should we build?')).toBeTruthy()
|
||||
expect(screen.getAllByText('What should we build?')).toHaveLength(1)
|
||||
expect(screen.getByText('A dashboard — A focused web surface.')).toBeTruthy()
|
||||
expect(screen.getByText('回答:')).toBeTruthy()
|
||||
expect(screen.getAllByText('A dashboard')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('copies the question with its answer, then confirms the write', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<QuestionReplyBubble data={data} t={t} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'copy' }))
|
||||
expect(writeText).toHaveBeenCalledWith('What should we build?\n回答:A dashboard')
|
||||
// Two microtask ticks: writeClipboard's own await, then the .then that
|
||||
// lands the success chrome.
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'copied' }))
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders through the conversation.chat.node slot with the same bubble', () => {
|
||||
renderView(data)
|
||||
expect(screen.getByRole('group', { name: '回答先前等待中的问题' }).getAttribute('data-question-reply')).toBe('call-reply')
|
||||
})
|
||||
|
||||
it('reads a dismissed reply as skipped, collapsed and open alike', () => {
|
||||
render(<QuestionReplyBubble data={{ ...data, outcome: 'dismissed', answers: [] }} t={t} />)
|
||||
expect(screen.getByRole('group', { name: '放弃回答先前等待中的问题' })).toBeTruthy()
|
||||
expect(screen.getByText('已跳过')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /展开问题详情/ }))
|
||||
expect(screen.getAllByText('已跳过')).toHaveLength(1)
|
||||
expect(screen.queryByText('A dashboard')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a distinct header, marks an unanswered question skipped, and falls back to the raw text', () => {
|
||||
render(<QuestionReplyBubble
|
||||
data={{
|
||||
...data,
|
||||
questions: [
|
||||
{ id: 'goal', question: 'What should we build?', header: 'Goal' },
|
||||
{ id: 'notes', question: 'Any notes?', options: [{ label: 'None' }] },
|
||||
],
|
||||
answers: [{ id: 'goal', selected: [], custom: ' ' }],
|
||||
}}
|
||||
t={t}
|
||||
/>)
|
||||
fireEvent.click(screen.getByRole('button', { name: /展开问题详情/ }))
|
||||
expect(screen.getByText('Goal')).toBeTruthy()
|
||||
expect(screen.getByText('None')).toBeTruthy()
|
||||
expect(screen.getAllByText('已跳过')).toHaveLength(2)
|
||||
cleanup()
|
||||
|
||||
render(<QuestionReplyBubble data={{ ...data, questions: [], answers: [], text: 'raw reply text' }} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /展开问题详情/ }))
|
||||
expect(screen.getByText('raw reply text')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the copy affordance quiet when the clipboard refuses, ignores a click while confirmed, and resets after a second', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const writeText = vi.fn().mockRejectedValueOnce(new Error('denied')).mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<QuestionReplyBubble data={data} t={t} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'copy' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'copy' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'copy' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'copied' }))
|
||||
expect(writeText).toHaveBeenCalledTimes(2)
|
||||
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.getByRole('button', { name: 'copy' })).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('copies the same text once the bubble is open', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<QuestionReplyBubble data={data} t={t} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /展开问题详情/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'copy' }))
|
||||
expect(writeText).toHaveBeenCalledWith('What should we build?\n回答:A dashboard')
|
||||
})
|
||||
})
|
||||
@@ -1,40 +1,29 @@
|
||||
/** Scoped Remote Event wiring and projection publishing for the browser question consumer. */
|
||||
/** Scoped Remote Event wiring for the browser question consumer. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { PendingUserQuestion, UserQuestionProjectionView } from '@deepseek-ai/dsh-user-questions/types'
|
||||
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
|
||||
import { PendingQuestion } from '../src/client/contract/slots.ts'
|
||||
import { createQuestionDraftStore } from '../src/client/draft-store.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { TimedQuestionWait } from '../../../interaction/user-questions/src/timed-wait.ts'
|
||||
|
||||
const SESSION_ID = 'session-question' as SessionId
|
||||
const SESSION_SCOPE = Symbol('question-session-scope')
|
||||
const CALL = ToolCallId('call-timed')
|
||||
const QUESTIONS = [{ id: 'mode', question: 'Choose a mode' }] as const
|
||||
const ANSWER = { answers: [{ id: 'mode', selected: ['Fast'] }] }
|
||||
const PLAN_QUESTIONS: PendingQuestion['questions'] = [{
|
||||
id: 'plan',
|
||||
question: 'Approve this plan?',
|
||||
detail: '# Plan',
|
||||
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
|
||||
intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
intent: { kind: 'plan-review' as const, approve: 'Approve' },
|
||||
}]
|
||||
/** One settled call as its tool call row reads it back. */
|
||||
const RECORD = { questions: [...QUESTIONS], answers: [...ANSWER.answers] }
|
||||
const CONTINUED: PendingUserQuestion = { callId: CALL, questions: [...QUESTIONS], state: 'continued' }
|
||||
/** The projection value this consumer reads; it acts on the answerable half alone. */
|
||||
const view = (active: readonly PendingUserQuestion[]): UserQuestionProjectionView => ({ active, settled: [] })
|
||||
const ANSWER = { answers: [{ id: 'mode', selected: ['Fast'] }] }
|
||||
|
||||
type QuestionRequest = {
|
||||
questions: PendingQuestion['questions']
|
||||
signal?: AbortSignal
|
||||
wait?: { callId: ToolCallId; timed?: boolean }
|
||||
}
|
||||
type QuestionAnswer = typeof ANSWER
|
||||
type QuestionNext = () => Promise<QuestionAnswer>
|
||||
@@ -43,22 +32,14 @@ type QuestionListener = (
|
||||
request: QuestionRequest,
|
||||
next: QuestionNext,
|
||||
) => Promise<QuestionAnswer>
|
||||
type RemoteBooleanResult = { ok: true; value: boolean } | { ok: false; error: { message: string } }
|
||||
|
||||
/** `absent` seeds a projection face that has published nothing yet. */
|
||||
async function bench(declare = true, durable: readonly PendingUserQuestion[] | 'absent' = [], remainingMs = 60_000) {
|
||||
async function bench(declare = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const slots = ctx.get('slots') as SlotRegistry
|
||||
if (declare) {
|
||||
slots.register(
|
||||
{
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
'conversation.chat.node': { kind: 'keyed', scope: 'session' },
|
||||
},
|
||||
} as never,
|
||||
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
@@ -68,19 +49,7 @@ async function bench(declare = true, durable: readonly PendingUserQuestion[] | '
|
||||
const scopeOf = vi.fn((candidate: Context) => (
|
||||
candidate as Context & { [SESSION_SCOPE]?: SessionId }
|
||||
)[SESSION_SCOPE])
|
||||
const projection = createSnapshotStore<UserQuestionProjectionView | undefined>(durable === 'absent' ? undefined : view(durable))
|
||||
const list = createSnapshotStore({
|
||||
ids: [SESSION_ID],
|
||||
byId: { [SESSION_ID]: { id: SESSION_ID } },
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
jobsBySession: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const binding = vi.fn((sessionId: SessionId) => sessionId === SESSION_ID
|
||||
? ({ sessionId: SESSION_ID, session: { projections: { faceOf: () => projection } } })
|
||||
: undefined)
|
||||
ctx.provide('sessions', { scopeOf, list, binding } as never)
|
||||
ctx.provide('sessions', { scopeOf } as never)
|
||||
const pending = new Map<PendingQuestion, () => Promise<void>>()
|
||||
const registerPendingInteraction = vi.fn((_precedence: (value: PendingQuestion) => number) => (
|
||||
value: PendingQuestion,
|
||||
@@ -91,40 +60,13 @@ async function bench(declare = true, durable: readonly PendingUserQuestion[] | '
|
||||
return () => { pending.delete(value) }
|
||||
})
|
||||
ctx.provide('uiSession', { registerPendingInteraction } as never)
|
||||
const registerNode = vi.fn((_definition: { kind: string }) => () => {})
|
||||
ctx.provide('uiConversation', { events: { register: registerNode } } as never)
|
||||
let listener: QuestionListener | undefined
|
||||
const on = vi.fn((event: string, value: QuestionListener) => {
|
||||
expect(event).toBe('user-questions/request')
|
||||
listener = value
|
||||
return () => { listener = undefined }
|
||||
})
|
||||
const remoteQuestions = {
|
||||
attachWait: vi.fn((_sessionId: SessionId, _callId: ToolCallId, signal: AbortSignal) => {
|
||||
const ended = Promise.withResolvers<IteratorResult<{ remainingMs: number }>>()
|
||||
const dispose = (): void => {
|
||||
signal.removeEventListener('abort', dispose)
|
||||
ended.resolve({ done: true, value: undefined })
|
||||
}
|
||||
signal.addEventListener('abort', dispose, { once: true })
|
||||
let first = true
|
||||
return {
|
||||
dispose, send: () => {}, end: () => {},
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => {
|
||||
if (!first) return ended.promise
|
||||
first = false
|
||||
return Promise.resolve({ done: false as const, value: { remainingMs } })
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
answer: vi.fn(async (
|
||||
_sessionId: SessionId, _callId: string, _answer: QuestionAnswer,
|
||||
): Promise<RemoteBooleanResult> => ({ ok: true, value: true })),
|
||||
}
|
||||
ctx.provide('remote.userQuestions', remoteQuestions as never)
|
||||
ctx.provide('remote', { $on: on, userQuestions: remoteQuestions } as never)
|
||||
ctx.provide('remote', { $on: on } as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const invoke = (
|
||||
@@ -142,16 +84,9 @@ async function bench(declare = true, durable: readonly PendingUserQuestion[] | '
|
||||
agent,
|
||||
scopeOf,
|
||||
pending: { getSnapshot: () => [...pending.keys()] },
|
||||
/** The answer-panel provider this plugin fills for the tool call row. */
|
||||
panels: () => ctx.get('userQuestionPanels'),
|
||||
registerPendingInteraction,
|
||||
registerNode,
|
||||
on,
|
||||
fiber,
|
||||
list,
|
||||
binding,
|
||||
projection,
|
||||
remoteQuestions,
|
||||
invoke,
|
||||
async releasePending() {
|
||||
const delegates = [...pending.values()]
|
||||
@@ -161,98 +96,9 @@ async function bench(declare = true, durable: readonly PendingUserQuestion[] | '
|
||||
}
|
||||
}
|
||||
|
||||
const timed = (): QuestionRequest => ({
|
||||
questions: QUESTIONS,
|
||||
wait: { callId: CALL, timed: true },
|
||||
})
|
||||
|
||||
describe('apply', () => {
|
||||
it('delegates an expired foreground wait and releases its claim stream', async () => {
|
||||
const b = await bench()
|
||||
const dispose = vi.fn()
|
||||
b.remoteQuestions.attachWait.mockImplementationOnce(() => ({
|
||||
dispose, send: () => {}, end: () => {},
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => Promise.resolve({ done: true as const, value: undefined }),
|
||||
}),
|
||||
}))
|
||||
try {
|
||||
const next = vi.fn(async () => ANSWER)
|
||||
|
||||
await expect(b.invoke(b.agent, timed(), next)).resolves.toBe(ANSWER)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(b.remoteQuestions.attachWait.mock.calls[0]![2].aborted).toBe(true)
|
||||
expect(b.pending.getSnapshot()[0]?.snapshot()).toMatchObject({ channel: 'none', countdown: undefined })
|
||||
b.projection.set(view([]))
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
} finally {
|
||||
await b.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches an indefinite request before publishing control back to the caller', async () => {
|
||||
const b = await bench()
|
||||
try {
|
||||
const result = b.invoke(b.agent, { questions: QUESTIONS }, async () => ANSWER)
|
||||
expect(b.pending.getSnapshot()[0]?.snapshot().channel).toBe('waterfall')
|
||||
await b.pending.getSnapshot()[0]!.answer(ANSWER)
|
||||
await expect(result).resolves.toEqual(ANSWER)
|
||||
} finally {
|
||||
await b.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a claim until the Host accepts an answer submitted after Take time', async () => {
|
||||
const b = await bench()
|
||||
vi.useFakeTimers()
|
||||
const wait = new TimedQuestionWait(Date.now() + 5_000, undefined, new Error('unclaimed timeout'))
|
||||
const claimed = Promise.withResolvers<undefined>()
|
||||
const released = Promise.withResolvers<undefined>()
|
||||
const dispose = vi.fn(() => { released.resolve(undefined) })
|
||||
b.remoteQuestions.attachWait.mockImplementationOnce((_sessionId, _callId, signal) => {
|
||||
const iterator = wait.attach(signal)[Symbol.asyncIterator]()
|
||||
let reads = 0
|
||||
return {
|
||||
dispose, send: () => {}, end: () => {},
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => {
|
||||
const result = iterator.next()
|
||||
if (++reads === 2) claimed.resolve(undefined)
|
||||
return result
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
try {
|
||||
const result = b.invoke(b.agent, timed(), async () => ANSWER)
|
||||
await claimed.promise
|
||||
const pending = b.pending.getSnapshot()[0]!
|
||||
pending.takeTime()
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
await pending.answer(ANSWER)
|
||||
await expect(result).resolves.toEqual(ANSWER)
|
||||
|
||||
// The event carrier sends the answer only after its Client listener returns.
|
||||
// Keep delivery withheld while the original deadline is already past.
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(wait.signal.aborted).toBe(false)
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
|
||||
wait.close(new Error('Host accepted the answer'))
|
||||
await released.promise
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
wait.close(new Error('test ended'))
|
||||
await b.fiber.dispose()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['sessions', 'remote', 'remote.userQuestions', 'uiSession', 'slots', 'locale', 'uiConversation'])
|
||||
expect(inject).toEqual(['sessions', 'remote', 'uiSession', 'slots', 'locale'])
|
||||
})
|
||||
|
||||
it('installs the Remote Event listener and delegates an unscoped request', async () => {
|
||||
@@ -267,31 +113,6 @@ describe('apply', () => {
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('registers the late-reply conversation node with the conversation runtime', async () => {
|
||||
const b = await bench()
|
||||
|
||||
expect(b.registerNode).toHaveBeenCalledOnce()
|
||||
expect(b.registerNode.mock.calls[0]![0]).toMatchObject({ kind: 'user-question-reply', target: 'chat' })
|
||||
expect(b.slots.entries('conversation.chat.node').map(entry => entry.options.key)).toEqual(['question-reply'])
|
||||
})
|
||||
|
||||
it('follows only bound Sessions and drops a projection subscription with its Session', async () => {
|
||||
const b = await bench(true, 'absent')
|
||||
const other = 'session-unbound' as SessionId
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
|
||||
b.list.set({ ...b.list.getSnapshot(), ids: [SESSION_ID, other], byId: { [SESSION_ID]: { id: SESSION_ID }, [other]: { id: other } } })
|
||||
expect(b.binding).toHaveBeenCalledWith(other)
|
||||
b.projection.set(view([CONTINUED]))
|
||||
expect(b.pending.getSnapshot().map(card => card.key)).toEqual([`question:${SESSION_ID}:${CALL}`])
|
||||
|
||||
// Unbinding the Session removes its cards and stops following its projection.
|
||||
b.list.set({ ...b.list.getSnapshot(), ids: [other], byId: { [other]: { id: other } } })
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
b.projection.set(view([{ ...CONTINUED, callId: ToolCallId('call-after-unbind') }]))
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('projects a scoped request through one stable composer and returns its answer', async () => {
|
||||
const b = await bench()
|
||||
const next = vi.fn(async () => ANSWER)
|
||||
@@ -300,23 +121,19 @@ describe('apply', () => {
|
||||
|
||||
const entry = b.slots.entries('conversation.composer')[0]!
|
||||
expect(entry.component).toBe(QuestionComposer)
|
||||
expect(entry.inject).toBeUndefined()
|
||||
expect(entry.locale).toBe('question')
|
||||
const store = entry.store as ReturnType<typeof createQuestionDraftStore>
|
||||
expect(store.create(SESSION_ID).getSnapshot()).toEqual({ progressByRequest: {} })
|
||||
expect(store.create(SESSION_ID).getSnapshot()).toEqual({
|
||||
progress: { index: 0, drafts: [] },
|
||||
})
|
||||
const pending = b.pending.getSnapshot()[0]!
|
||||
const face = (entry.inject as () => { keyedHooks: { questionCard: (key: string) => PendingQuestion | undefined } })()
|
||||
expect(face.keyedHooks.questionCard(pending.key)).toBe(pending)
|
||||
expect(face.keyedHooks.questionCard('question:missing')).toBeUndefined()
|
||||
const select = entry.select as (
|
||||
owner: { pendingInteraction: PendingQuestion | undefined },
|
||||
) => PendingQuestion | null
|
||||
expect(select({ pendingInteraction: undefined })).toBeNull()
|
||||
expect(select({ pendingInteraction: pending })).toBe(pending)
|
||||
expect(pending).toMatchObject({ kind: 'question', sessionId: SESSION_ID, questions: QUESTIONS, callId: undefined })
|
||||
expect(pending.snapshot()).toEqual({
|
||||
state: 'open', waitState: 'counting', countdown: undefined,
|
||||
channel: 'waterfall', closed: false,
|
||||
})
|
||||
expect(pending).toMatchObject({ kind: 'question', sessionId: SESSION_ID, questions: QUESTIONS })
|
||||
|
||||
await pending.answer(ANSWER)
|
||||
await expect(result).resolves.toBe(ANSWER)
|
||||
@@ -325,24 +142,36 @@ describe('apply', () => {
|
||||
expect(b.slots.entries('conversation.composer')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a request the Host never named ends with ASK_CANCELLED when the user closes its panel', async () => {
|
||||
it('preserves ASK_CANCELLED as a rejected waterfall result', async () => {
|
||||
const b = await bench()
|
||||
const result = b.invoke(b.agent, { questions: QUESTIONS }, async () => ANSWER)
|
||||
await Promise.resolve()
|
||||
const pending = b.pending.getSnapshot()[0]!
|
||||
expect(pending.dismissal).toBe('cancel')
|
||||
const rejection = expect(result).rejects.toMatchObject({
|
||||
name: 'UserQuestionError',
|
||||
code: 'ASK_CANCELLED',
|
||||
message: 'the user cancelled ask_user_question',
|
||||
})
|
||||
|
||||
await pending.dismiss()
|
||||
await pending.cancel()
|
||||
await rejection
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
expect(b.slots.entries('conversation.composer')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('publishes a plan-review request with its distinct interaction kind', async () => {
|
||||
const b = await bench()
|
||||
const result = b.invoke(b.agent, { questions: PLAN_QUESTIONS }, async () => ANSWER)
|
||||
await Promise.resolve()
|
||||
const pending = b.pending.getSnapshot()[0]!
|
||||
|
||||
expect(pending.kind).toBe('plan-review')
|
||||
await pending.answer(ANSWER)
|
||||
await expect(result).resolves.toBe(ANSWER)
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('removes an aborted blocking request while preserving the stable composer', async () => {
|
||||
it('removes a cancelled request while preserving the stable composer', async () => {
|
||||
const b = await bench()
|
||||
const controller = new AbortController()
|
||||
const result = b.invoke(b.agent, { questions: QUESTIONS, signal: controller.signal }, async () => ANSWER)
|
||||
@@ -356,271 +185,116 @@ describe('apply', () => {
|
||||
expect(b.slots.entries('conversation.composer')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('delegates an active request when its interaction domain unloads, and settles the release only once the listener has handed it on', async () => {
|
||||
it('delegates an active request when its interaction domain unloads', async () => {
|
||||
const b = await bench()
|
||||
const handedOn = Promise.withResolvers<typeof ANSWER>()
|
||||
const next = vi.fn(() => handedOn.promise)
|
||||
const next = vi.fn(async () => ANSWER)
|
||||
const result = b.invoke(b.agent, { questions: QUESTIONS }, next)
|
||||
await Promise.resolve()
|
||||
expect(b.pending.getSnapshot()).toHaveLength(1)
|
||||
|
||||
let released = false
|
||||
const release = b.releasePending().then(() => { released = true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
// The registry's release awaits the listener's `next()`, exactly as the
|
||||
// blocking question always did, so a plugin teardown never resolves ahead
|
||||
// of the request it is handing back.
|
||||
await b.releasePending()
|
||||
|
||||
await expect(result).resolves.toBe(ANSWER)
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(released).toBe(false)
|
||||
|
||||
handedOn.resolve(ANSWER)
|
||||
await release
|
||||
|
||||
expect(released).toBe(true)
|
||||
await expect(result).resolves.toBe(ANSWER)
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('keys a timed request by Session and call and settles it with ASK_TIMED_OUT at the local deadline', async () => {
|
||||
it('removes the stable composer with the plugin lifetime', async () => {
|
||||
const b = await bench()
|
||||
const result = b.invoke(b.agent, timed(), async () => ANSWER)
|
||||
await Promise.resolve()
|
||||
const pending = b.pending.getSnapshot()[0]!
|
||||
expect(pending.key).toBe(`question:${SESSION_ID}:${CALL}`)
|
||||
expect(pending.dismissal).toBe('hide')
|
||||
expect(pending.snapshot()).toMatchObject({
|
||||
state: 'open', waitState: 'counting', channel: 'waterfall', closed: false,
|
||||
})
|
||||
// The card owns the countdown, so it reads the deadline down from now.
|
||||
expect(pending.snapshot().countdown).toEqual({ remainingMs: expect.any(Number) as number, running: true })
|
||||
expect(pending.snapshot().countdown!.remainingMs).toBeGreaterThan(55_000)
|
||||
const rejection = expect(result).rejects.toMatchObject({ name: 'UserQuestionError', code: 'ASK_TIMED_OUT' })
|
||||
|
||||
pending.timeout()
|
||||
|
||||
await rejection
|
||||
expect(b.pending.getSnapshot()).toEqual([pending])
|
||||
expect(pending.snapshot()).toEqual({
|
||||
state: 'open', waitState: 'counting', countdown: undefined,
|
||||
channel: 'none', closed: false,
|
||||
})
|
||||
await expect(pending.answer(ANSWER)).rejects.toThrow(/no channel/)
|
||||
})
|
||||
|
||||
it('anchors a Host-measured remaining wait to the Client clock', async () => {
|
||||
const b = await bench(true, [], 2_000)
|
||||
const request = timed()
|
||||
const result = b.invoke(b.agent, request, async () => ANSWER)
|
||||
await Promise.resolve()
|
||||
const pending = b.pending.getSnapshot()[0]!
|
||||
expect(pending.snapshot().countdown!.remainingMs).toBeLessThanOrEqual(2_000)
|
||||
expect(pending.snapshot().countdown!.remainingMs).toBeGreaterThan(1_000)
|
||||
pending.timeout()
|
||||
await expect(result).rejects.toMatchObject({ code: 'ASK_TIMED_OUT' })
|
||||
})
|
||||
|
||||
it('a card-keyed request without a deadline waits with no countdown', async () => {
|
||||
const b = await bench()
|
||||
// Nothing settles an indefinite wait, so the request stays pending past this test.
|
||||
void b.invoke(b.agent, { questions: QUESTIONS, wait: { callId: CALL } }, async () => ANSWER)
|
||||
await Promise.resolve()
|
||||
|
||||
const pending = b.pending.getSnapshot()[0]!
|
||||
expect(pending.key).toBe(`question:${SESSION_ID}:${CALL}`)
|
||||
expect(pending.dismissal).toBe('hide')
|
||||
expect(pending.snapshot()).toEqual({
|
||||
state: 'open', waitState: 'counting', countdown: undefined,
|
||||
channel: 'waterfall', closed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('closing a card-keyed panel only leaves the seat, and the panel provider brings it back', async () => {
|
||||
const b = await bench()
|
||||
const next = vi.fn(async () => ANSWER)
|
||||
const result = b.invoke(b.agent, timed(), next)
|
||||
await Promise.resolve()
|
||||
const pending = b.pending.getSnapshot()[0]!
|
||||
|
||||
await pending.dismiss()
|
||||
|
||||
// Nothing settled: the tool call still waits, and its countdown still runs.
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
expect(pending.snapshot()).toMatchObject({ channel: 'waterfall', closed: false })
|
||||
|
||||
expect(b.panels()?.reveal(SESSION_ID, CALL)).toBe(true)
|
||||
expect(b.pending.getSnapshot()).toEqual([pending])
|
||||
|
||||
await pending.answer(ANSWER)
|
||||
await expect(result).resolves.toBe(ANSWER)
|
||||
})
|
||||
|
||||
it('reveals nothing for a call this Client holds no card for', async () => {
|
||||
const b = await bench()
|
||||
expect(b.panels()?.reveal(SESSION_ID, 'call-unknown')).toBe(false)
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('builds a read-only card from a settled call record and drops it when closed', async () => {
|
||||
const b = await bench()
|
||||
|
||||
expect(b.panels()?.review(SESSION_ID, CALL, RECORD)).toBe(true)
|
||||
const card = b.pending.getSnapshot()[0]!
|
||||
expect(card.key).toBe(`question:${SESSION_ID}:${CALL}`)
|
||||
expect(card.review).toEqual(RECORD.answers)
|
||||
expect(card.liveKeys()).toEqual([card.key])
|
||||
// No channel and no countdown: the call settled, so nothing is answerable.
|
||||
expect(card.snapshot()).toEqual({
|
||||
state: 'open', waitState: 'counting', countdown: undefined,
|
||||
channel: 'none', closed: false,
|
||||
})
|
||||
await expect(card.answer(ANSWER)).rejects.toThrow(/no channel accepts an answer yet/)
|
||||
|
||||
// The projection never lists a settled call; the sweep must leave it alone.
|
||||
b.projection.set(view([]))
|
||||
expect(b.pending.getSnapshot()).toEqual([card])
|
||||
|
||||
await card.dismiss()
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
expect(card.snapshot().closed).toBe(true)
|
||||
|
||||
// The row builds another card from the same record.
|
||||
expect(b.panels()?.review(SESSION_ID, CALL, RECORD)).toBe(true)
|
||||
expect(b.pending.getSnapshot()[0]).not.toBe(card)
|
||||
})
|
||||
|
||||
it('a review of a call that still holds a live card shows the live one', async () => {
|
||||
const b = await bench()
|
||||
const result = b.invoke(b.agent, timed(), async () => ANSWER)
|
||||
await Promise.resolve()
|
||||
const card = b.pending.getSnapshot()[0]!
|
||||
|
||||
expect(b.panels()?.review(SESSION_ID, CALL, RECORD)).toBe(true)
|
||||
|
||||
expect(b.pending.getSnapshot()).toEqual([card])
|
||||
expect(card.review).toBeUndefined()
|
||||
expect(card.snapshot().channel).toBe('waterfall')
|
||||
await card.answer(ANSWER)
|
||||
await expect(result).resolves.toBe(ANSWER)
|
||||
})
|
||||
|
||||
it('withdraws the panel provider with the plugin lifetime', async () => {
|
||||
const b = await bench()
|
||||
expect(b.panels()).toBeDefined()
|
||||
|
||||
await b.fiber.dispose()
|
||||
|
||||
expect(b.panels()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a timed card through a cancelled delivery until the projection closes it', async () => {
|
||||
const b = await bench()
|
||||
const controller = new AbortController()
|
||||
const result = b.invoke(b.agent, { ...timed(), signal: controller.signal }, async () => ANSWER)
|
||||
await Promise.resolve()
|
||||
const pending = b.pending.getSnapshot()[0]!
|
||||
|
||||
controller.abort()
|
||||
await expect(result).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
expect(b.pending.getSnapshot()).toEqual([pending])
|
||||
expect(pending.snapshot().channel).toBe('none')
|
||||
|
||||
b.projection.set(view([CONTINUED]))
|
||||
expect(b.pending.getSnapshot()).toEqual([pending])
|
||||
expect(pending.snapshot()).toMatchObject({ state: 'continued', channel: 'rpc' })
|
||||
|
||||
b.projection.set(view([]))
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
expect(pending.snapshot().closed).toBe(true)
|
||||
})
|
||||
|
||||
it('creates a continued card from the projection and answers it through the Remote path', async () => {
|
||||
const b = await bench()
|
||||
|
||||
b.projection.set(view([CONTINUED, { callId: ToolCallId('call-other'), questions: [...QUESTIONS], state: 'continued' }]))
|
||||
const cards = b.pending.getSnapshot()
|
||||
expect(cards.map(card => card.key)).toEqual([
|
||||
`question:${SESSION_ID}:${CALL}`, `question:${SESSION_ID}:call-other`,
|
||||
])
|
||||
const card = cards[0]!
|
||||
expect(card.snapshot()).toMatchObject({ state: 'continued', channel: 'rpc' })
|
||||
expect(card.liveKeys()).toEqual(cards.map(item => item.key))
|
||||
|
||||
await card.answer(ANSWER)
|
||||
expect(b.remoteQuestions.answer).toHaveBeenCalledWith(SESSION_ID, CALL, ANSWER)
|
||||
|
||||
b.remoteQuestions.answer.mockResolvedValueOnce({ ok: true, value: false })
|
||||
await expect(card.answer(ANSWER)).rejects.toThrow(/no longer answerable/)
|
||||
b.remoteQuestions.answer.mockResolvedValueOnce({ ok: false, error: { message: 'offline' } })
|
||||
await expect(card.answer(ANSWER)).rejects.toThrow('offline')
|
||||
})
|
||||
|
||||
it('publishes a plan-review request ahead of plain questions', async () => {
|
||||
const b = await bench()
|
||||
const result = b.invoke(b.agent, { questions: PLAN_QUESTIONS }, async () => ANSWER)
|
||||
await Promise.resolve()
|
||||
|
||||
const pending = b.pending.getSnapshot()[0]!
|
||||
expect(pending.kind).toBe('plan-review')
|
||||
const precedence = b.registerPendingInteraction.mock.calls[0]![0]
|
||||
expect(precedence(pending)).toBe(2)
|
||||
expect(precedence(new PendingQuestion(SESSION_ID, QUESTIONS))).toBe(1)
|
||||
|
||||
await pending.answer(ANSWER)
|
||||
await expect(result).resolves.toBe(ANSWER)
|
||||
})
|
||||
|
||||
it('closing a continued panel keeps the question answerable from its tool call', async () => {
|
||||
const b = await bench()
|
||||
b.projection.set(view([CONTINUED]))
|
||||
const card = b.pending.getSnapshot()[0]!
|
||||
|
||||
await card.dismiss()
|
||||
|
||||
// No Remote call and no projection change: the dismissal is Client-local.
|
||||
expect(b.remoteQuestions.answer).not.toHaveBeenCalled()
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
expect(card.snapshot()).toMatchObject({ state: 'continued', channel: 'rpc', closed: false })
|
||||
|
||||
expect(b.panels()?.reveal(SESSION_ID, CALL)).toBe(true)
|
||||
expect(b.pending.getSnapshot()).toEqual([card])
|
||||
})
|
||||
|
||||
it('ignores open rows and lets a re-delivered request attach to a projection-created card', async () => {
|
||||
const b = await bench(true, [{ ...CONTINUED, state: 'open' }])
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
|
||||
b.projection.set(view([CONTINUED]))
|
||||
const card = b.pending.getSnapshot()[0]!
|
||||
const result = b.invoke(b.agent, timed(), async () => ANSWER)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(b.pending.getSnapshot()).toEqual([card])
|
||||
expect(card.snapshot()).toMatchObject({ state: 'continued', channel: 'waterfall' })
|
||||
await card.answer(ANSWER)
|
||||
await expect(result).resolves.toBe(ANSWER)
|
||||
expect(b.remoteQuestions.answer).not.toHaveBeenCalled()
|
||||
expect(b.pending.getSnapshot()).toEqual([card])
|
||||
|
||||
b.projection.set(view([]))
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('stops following the projection with the plugin lifetime', async () => {
|
||||
const b = await bench(true, [CONTINUED])
|
||||
const card = b.pending.getSnapshot()[0]!
|
||||
expect(b.slots.entries('conversation.composer')).toHaveLength(1)
|
||||
|
||||
await b.fiber.dispose()
|
||||
|
||||
// Teardown drops every card, hidden ones included: a hidden card is not in
|
||||
// the pending-interaction registry, so nothing else would end its request.
|
||||
expect(b.slots.entries('conversation.composer')).toHaveLength(0)
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
expect(card.snapshot().closed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
b.projection.set(view([CONTINUED, { callId: ToolCallId('call-after-dispose'), questions: [...QUESTIONS], state: 'continued' }]))
|
||||
expect(b.pending.getSnapshot()).toEqual([])
|
||||
describe('PendingQuestion', () => {
|
||||
it('preserves an already-aborted request signal as ASK_ABORTED', async () => {
|
||||
const lifetime = new AbortController()
|
||||
lifetime.abort()
|
||||
const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal)
|
||||
|
||||
await expect(pending.result).rejects.toMatchObject({
|
||||
name: 'UserQuestionError',
|
||||
code: 'ASK_ABORTED',
|
||||
message: 'ask_user_question was aborted before the user answered',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects on later request cancellation and removes the listener after settlement', async () => {
|
||||
const lifetime = new AbortController()
|
||||
const remove = vi.spyOn(lifetime.signal, 'removeEventListener')
|
||||
const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal)
|
||||
const rejected = expect(pending.result).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
|
||||
lifetime.abort()
|
||||
|
||||
await rejected
|
||||
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function))
|
||||
})
|
||||
|
||||
it('ignores a lifecycle abort after the answer already settled', async () => {
|
||||
const lifetime = new AbortController()
|
||||
const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal)
|
||||
|
||||
await pending.answer(ANSWER)
|
||||
await expect(pending.result).resolves.toBe(ANSWER)
|
||||
pending.abort(new Error('late disposal'))
|
||||
pending.delegate()
|
||||
})
|
||||
|
||||
it('rejects an unanswered request with its caller-owned lifecycle reason', async () => {
|
||||
const pending = new PendingQuestion(SESSION_ID, QUESTIONS)
|
||||
const reason = new Error('scope released')
|
||||
const rejected = expect(pending.result).rejects.toBe(reason)
|
||||
|
||||
pending.abort(reason)
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('wraps a non-Error answer settlement failure with its cause', async () => {
|
||||
const failure = 'resolve failed'
|
||||
const completion = Promise.withResolvers<QuestionAnswer>()
|
||||
const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(() => ({
|
||||
promise: completion.promise,
|
||||
resolve: () => { throw failure },
|
||||
reject: completion.reject,
|
||||
}))
|
||||
const pending = new PendingQuestion(SESSION_ID, QUESTIONS)
|
||||
withResolvers.mockRestore()
|
||||
|
||||
const settlement = await pending.answer(ANSWER).catch((error: unknown) => error)
|
||||
|
||||
expect(settlement).toBeInstanceOf(Error)
|
||||
expect(settlement).toMatchObject({
|
||||
message: 'pending question settlement failed',
|
||||
cause: failure,
|
||||
})
|
||||
completion.resolve(ANSWER)
|
||||
await expect(pending.result).resolves.toBe(ANSWER)
|
||||
})
|
||||
|
||||
it('wraps a non-Error cancellation settlement failure with its cause', async () => {
|
||||
const failure = 'reject failed'
|
||||
const completion = Promise.withResolvers<QuestionAnswer>()
|
||||
const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(<T>() => ({
|
||||
promise: completion.promise,
|
||||
resolve: completion.resolve as (value: T | PromiseLike<T>) => void,
|
||||
reject: () => { throw failure },
|
||||
}))
|
||||
const pending = new PendingQuestion(SESSION_ID, QUESTIONS)
|
||||
withResolvers.mockRestore()
|
||||
|
||||
const settlement = await pending.cancel().catch((error: unknown) => error)
|
||||
|
||||
expect(settlement).toBeInstanceOf(Error)
|
||||
expect(settlement).toMatchObject({
|
||||
message: 'pending question cancellation failed',
|
||||
cause: failure,
|
||||
})
|
||||
completion.resolve(ANSWER)
|
||||
await expect(pending.result).resolves.toBe(ANSWER)
|
||||
})
|
||||
})
|
||||
@@ -1,161 +0,0 @@
|
||||
/** Card and waterfall settlement edges the composer and plugin specs do not reach. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { createWaterfallRequest, PendingQuestion } from '../src/client/contract/slots.ts'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
const CALL = ToolCallId('call-1')
|
||||
const QUESTIONS = [{ id: 'mode', question: 'Choose a mode' }]
|
||||
const ANSWER = { answers: [{ id: 'mode', selected: ['Fast'] }] }
|
||||
|
||||
function attached(card: PendingQuestion, deadline?: number) {
|
||||
const request = createWaterfallRequest(deadline, undefined, (channel) => { card.detachWaterfall(channel) })
|
||||
card.attachWaterfall(request.channel)
|
||||
return { card, request }
|
||||
}
|
||||
|
||||
describe('createWaterfallRequest', () => {
|
||||
it('settles once: later resolutions, rejections and delegations are ignored', async () => {
|
||||
const onSettle = vi.fn()
|
||||
const request = createWaterfallRequest(1_000, undefined, onSettle)
|
||||
request.channel.resolve(ANSWER)
|
||||
request.channel.reject('ASK_CANCELLED')
|
||||
request.channel.delegate()
|
||||
await expect(request.result).resolves.toBe(ANSWER)
|
||||
expect(onSettle).toHaveBeenCalledOnce()
|
||||
expect(request.channel.deadline).toBe(1_000)
|
||||
})
|
||||
|
||||
it('rejects immediately when the request arrives already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const onSettle = vi.fn()
|
||||
const request = createWaterfallRequest(undefined, controller.signal, onSettle)
|
||||
await expect(request.result).rejects.toMatchObject({ name: 'UserQuestionError', code: 'ASK_ABORTED' })
|
||||
expect(onSettle).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PendingQuestion', () => {
|
||||
it.each([true, false])('retains focus before a deadline arrives only while still focused: %s', (stillFocused) => {
|
||||
vi.useFakeTimers()
|
||||
const card = new PendingQuestion(SID, QUESTIONS, CALL)
|
||||
try {
|
||||
card.holdFocus()
|
||||
if (!stillFocused) card.releaseFocus()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
attached(card, Date.now() + 60_000)
|
||||
expect(card.snapshot()).toMatchObject({
|
||||
waitState: stillFocused ? 'focused' : 'counting',
|
||||
countdown: { remainingMs: 60_000, running: !stillFocused },
|
||||
})
|
||||
card.releaseFocus()
|
||||
expect(card.snapshot().countdown).toEqual({ remainingMs: 60_000, running: true })
|
||||
} finally {
|
||||
card.close()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not start a timer when an indefinite question loses focus', () => {
|
||||
vi.useFakeTimers()
|
||||
const card = new PendingQuestion(SID, QUESTIONS)
|
||||
try {
|
||||
attached(card)
|
||||
card.holdFocus()
|
||||
card.releaseFocus()
|
||||
expect(card.snapshot().countdown).toBeUndefined()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
card.close()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes only real state changes', () => {
|
||||
const card = new PendingQuestion(SID, QUESTIONS, CALL)
|
||||
const listener = vi.fn()
|
||||
card.subscribe(listener)
|
||||
|
||||
card.setState('open')
|
||||
card.timeout()
|
||||
card.takeTime()
|
||||
card.takeTime()
|
||||
card.timeout()
|
||||
card.close()
|
||||
card.close()
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(card.snapshot()).toMatchObject({ state: 'open', countdown: undefined, channel: 'none', closed: true })
|
||||
expect(card.liveKeys()).toEqual([card.key])
|
||||
})
|
||||
|
||||
it('keeps a held or edited card without a countdown when a later request attaches', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const now = Date.now()
|
||||
const held = new PendingQuestion(SID, QUESTIONS, CALL)
|
||||
held.takeTime()
|
||||
attached(held, now + 60_000)
|
||||
expect(held.snapshot()).toMatchObject({ waitState: 'waiting', countdown: { remainingMs: 0, running: false } })
|
||||
|
||||
const edited = new PendingQuestion(SID, QUESTIONS, CALL)
|
||||
edited.engage(now)
|
||||
attached(edited, now + 60_000)
|
||||
expect(edited.snapshot()).toMatchObject({ waitState: 'editing', countdown: { remainingMs: 0, running: false } })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('freezes the remaining wait while focused, also across a re-delivered request', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const now = Date.now()
|
||||
const card = new PendingQuestion(SID, QUESTIONS, CALL)
|
||||
const first = attached(card, now + 60_000)
|
||||
card.holdFocus(now + 10_000)
|
||||
expect(card.snapshot()).toMatchObject({ waitState: 'focused', countdown: { remainingMs: 50_000, running: false } })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
// The focused hold survives the request being re-delivered: a deadline
|
||||
// carried by the new channel is frozen too, one without leaves it as is.
|
||||
card.detachWaterfall(first.request.channel)
|
||||
attached(card, now + 40_000)
|
||||
expect(card.snapshot()).toMatchObject({ waitState: 'focused', countdown: { remainingMs: 40_000, running: false } })
|
||||
attached(card)
|
||||
expect(card.snapshot()).toMatchObject({ waitState: 'focused', countdown: undefined })
|
||||
|
||||
card.releaseFocus(now + 20_000)
|
||||
expect(card.snapshot()).toMatchObject({ waitState: 'counting', countdown: undefined })
|
||||
card.releaseFocus(now + 20_000)
|
||||
expect(card.snapshot().waitState).toBe('counting')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to end an unnamed request that has no live channel', async () => {
|
||||
const card = new PendingQuestion(SID, QUESTIONS)
|
||||
expect(card.dismissal).toBe('cancel')
|
||||
await expect(card.dismiss()).rejects.toThrow(/no channel accepts a cancellation/)
|
||||
})
|
||||
|
||||
it('turns a throwing settlement into the rejected submission', async () => {
|
||||
const boom = new Error('subscriber failed')
|
||||
const first = attached(new PendingQuestion(SID, QUESTIONS, CALL))
|
||||
first.card.subscribe(() => { throw boom })
|
||||
await expect(first.card.answer(ANSWER)).rejects.toBe(boom)
|
||||
|
||||
const second = attached(new PendingQuestion(SID, QUESTIONS))
|
||||
const cancelled = expect(second.request.result).rejects.toMatchObject({ code: 'ASK_CANCELLED' })
|
||||
second.card.subscribe(() => { throw 'not an error' })
|
||||
await expect(second.card.dismiss()).rejects.toMatchObject({
|
||||
message: 'pending question cancellation failed', cause: 'not an error',
|
||||
})
|
||||
await cancelled
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import {
|
||||
createWaterfallRequest, PendingQuestion, planReviewOf, type QuestionComposerProps, type QuestionWait,
|
||||
PendingQuestion, planReviewOf, type QuestionComposerProps, type QuestionWait,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
import { createQuestionDraftStore } from '../src/client/draft-store.ts'
|
||||
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
|
||||
@@ -110,7 +110,6 @@ const questionDraftStore = createQuestionDraftStore().create(SID)
|
||||
|
||||
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
|
||||
const kit: Omit<QuestionComposerProps, 'matched'> = {
|
||||
useQuestionCard: () => { throw new Error('plan review does not read question card state') },
|
||||
renderSlot: () => null,
|
||||
SessionProvider: ({ children }) => children,
|
||||
sessionId: SID,
|
||||
@@ -159,11 +158,9 @@ const questions = (): QuestionWait['questions'] => [{
|
||||
/** Pending waterfall fixture with observable Client response methods. */
|
||||
function wait(items: QuestionWait['questions'] = questions()) {
|
||||
const carrier = new PendingQuestion(SID, items)
|
||||
const request = createWaterfallRequest(undefined, undefined, (channel) => { carrier.detachWaterfall(channel) })
|
||||
carrier.attachWaterfall(request.channel)
|
||||
const answer = vi.spyOn(carrier, 'answer')
|
||||
const cancel = vi.spyOn(carrier, 'dismiss')
|
||||
void request.result.catch(() => {})
|
||||
const cancel = vi.spyOn(carrier, 'cancel')
|
||||
void carrier.result.catch(() => {})
|
||||
return { carrier, answer, cancel }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Question-composer Session store behavior. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createQuestionDraftStore, type QuestionDraftProgress } from '../src/client/draft-store.ts'
|
||||
|
||||
const FIRST: QuestionDraftProgress = {
|
||||
@@ -8,44 +8,20 @@ const FIRST: QuestionDraftProgress = {
|
||||
}
|
||||
|
||||
describe('createQuestionDraftStore', () => {
|
||||
it('persists unfinished answers across Session store recreation', () => {
|
||||
const values = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { values.set(key, value) },
|
||||
removeItem: (key: string) => { values.delete(key) },
|
||||
})
|
||||
try {
|
||||
const first = createQuestionDraftStore().create('session-one')
|
||||
first.actions.replace('question:one', FIRST)
|
||||
|
||||
const restored = createQuestionDraftStore().create('session-one')
|
||||
const other = createQuestionDraftStore().create('session-two')
|
||||
expect(restored.getSnapshot()).toEqual({ progressByRequest: { 'question:one': FIRST } })
|
||||
expect(other.getSnapshot()).toEqual({ progressByRequest: {} })
|
||||
|
||||
restored.actions.clear('question:one')
|
||||
expect(createQuestionDraftStore().create('session-one').getSnapshot())
|
||||
.toEqual({ progressByRequest: {} })
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps request progress and ignores cleanup for another request', () => {
|
||||
it('keeps one request progress and ignores cleanup from an obsolete request', () => {
|
||||
const store = createQuestionDraftStore().create('session-one')
|
||||
|
||||
store.actions.replace('question:one', FIRST)
|
||||
expect(store.getSnapshot()).toEqual({ progressByRequest: { 'question:one': FIRST } })
|
||||
expect(store.getSnapshot()).toEqual({ requestKey: 'question:one', progress: FIRST })
|
||||
|
||||
store.actions.clear('question:older')
|
||||
expect(store.getSnapshot()).toEqual({ progressByRequest: { 'question:one': FIRST } })
|
||||
expect(store.getSnapshot()).toEqual({ requestKey: 'question:one', progress: FIRST })
|
||||
|
||||
store.actions.clear('question:one')
|
||||
expect(store.getSnapshot()).toEqual({ progressByRequest: {} })
|
||||
expect(store.getSnapshot()).toEqual({ progress: { index: 0, drafts: [] } })
|
||||
})
|
||||
|
||||
it('keeps simultaneous request drafts isolated', () => {
|
||||
it('replaces the previous request atomically instead of accumulating drafts', () => {
|
||||
const store = createQuestionDraftStore().create('session-one')
|
||||
const second: QuestionDraftProgress = {
|
||||
index: 0,
|
||||
@@ -55,22 +31,6 @@ describe('createQuestionDraftStore', () => {
|
||||
store.actions.replace('question:one', FIRST)
|
||||
store.actions.replace('question:two', second)
|
||||
|
||||
expect(store.getSnapshot()).toEqual({
|
||||
progressByRequest: { 'question:one': FIRST, 'question:two': second },
|
||||
})
|
||||
|
||||
store.actions.clear('question:two')
|
||||
expect(store.getSnapshot()).toEqual({ progressByRequest: { 'question:one': FIRST } })
|
||||
})
|
||||
|
||||
it('prunes every draft that no live card owns', () => {
|
||||
const store = createQuestionDraftStore().create('session-one')
|
||||
store.actions.replace('question:session-one:call-live', FIRST)
|
||||
store.actions.replace('question:session-one:call-closed', FIRST)
|
||||
store.actions.replace('question:7', FIRST)
|
||||
|
||||
store.actions.prune(['question:session-one:call-live'])
|
||||
|
||||
expect(store.getSnapshot()).toEqual({ progressByRequest: { 'question:session-one:call-live': FIRST } })
|
||||
expect(store.getSnapshot()).toEqual({ requestKey: 'question:two', progress: second })
|
||||
})
|
||||
})
|
||||
@@ -1,168 +0,0 @@
|
||||
/** Late-reply conversation node: source matching and payload projection. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import { messageDefinition } from '../../ui-chat/src/client/conversation-nodes/message.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import type { QuestionReplyData } from '../src/client/question-reply.ts'
|
||||
import { questionReplyDefinition, replyAnswerValues, replyClipboardText, replyPairsOf } from '../src/client/question-reply.ts'
|
||||
|
||||
const answered = JSON.stringify({
|
||||
kind: 'answer_to_pending_question',
|
||||
tool: 'ask_user_question',
|
||||
callId: 'call-1',
|
||||
questions: [{
|
||||
id: 'scope', question: 'Which scope?', header: 'Scope', detail: 'Choose the smallest useful surface.',
|
||||
options: [{ label: 'Tool only', description: 'Keep the change narrow.' }], multiSelect: false,
|
||||
}, { id: 'notes', question: 'Any notes?' }],
|
||||
answers: [{ id: 'scope', selected: ['Tool only'], custom: 'note' }, { id: 'notes', selected: [] }],
|
||||
})
|
||||
const dismissed = JSON.stringify({
|
||||
kind: 'dismissed_pending_question',
|
||||
tool: 'ask_user_question',
|
||||
callId: 'call-1',
|
||||
questions: [{ id: 'scope', question: 'Which scope?' }],
|
||||
message: 'The user dismissed these pending questions without answering.',
|
||||
})
|
||||
|
||||
function message(source: Record<string, unknown>, text: string, surfaceOp: string | null = 'append') {
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq: SessionSeq(7),
|
||||
time: 7_000,
|
||||
...(surfaceOp === null ? {} : { surfaceOp }),
|
||||
data: { id: 'msg-1', role: 'user', source, content: [{ type: 'text', text }] },
|
||||
} as never
|
||||
}
|
||||
|
||||
const replySource = (outcome: string) => ({ kind: 'user-question-reply', callId: 'call-1', outcome })
|
||||
|
||||
describe('replyPairsOf', () => {
|
||||
it('reads question and answer pairs, tolerating a payload without answers', () => {
|
||||
expect(replyPairsOf(answered)).toEqual({
|
||||
questions: [{
|
||||
id: 'scope', question: 'Which scope?', header: 'Scope', detail: 'Choose the smallest useful surface.',
|
||||
options: [{ label: 'Tool only', description: 'Keep the change narrow.' }], multiSelect: false,
|
||||
}, { id: 'notes', question: 'Any notes?' }],
|
||||
answers: [{ id: 'scope', selected: ['Tool only'], custom: 'note' }, { id: 'notes', selected: [] }],
|
||||
})
|
||||
expect(replyPairsOf(dismissed)).toEqual({ questions: [{ id: 'scope', question: 'Which scope?' }], answers: [] })
|
||||
})
|
||||
|
||||
it('keeps only the well-formed options of a question and drops the rest', () => {
|
||||
const text = JSON.stringify({
|
||||
questions: [{ id: 'scope', question: 'Which scope?', options: [{ label: 'Tool only', description: 1 }, 'nope', { description: 'no label' }] }],
|
||||
answers: [],
|
||||
})
|
||||
expect(replyPairsOf(text).questions).toEqual([{ id: 'scope', question: 'Which scope?', options: [{ label: 'Tool only' }] }])
|
||||
})
|
||||
|
||||
it('renders nothing structured for text it cannot read', () => {
|
||||
expect(replyPairsOf('not json')).toEqual({ questions: [], answers: [] })
|
||||
expect(replyPairsOf('{"questions":"nope"}')).toEqual({ questions: [], answers: [] })
|
||||
expect(replyPairsOf('{"questions":[{"id":1}],"answers":[{"id":"x"}]}')).toEqual({ questions: [], answers: [] })
|
||||
})
|
||||
})
|
||||
|
||||
const t = makeTranslate(en)
|
||||
const replyData = (overrides: Partial<QuestionReplyData> = {}): QuestionReplyData => ({
|
||||
callId: 'call-1',
|
||||
outcome: 'answered',
|
||||
questions: [{
|
||||
id: 'scope', question: 'Which scope?', header: 'Scope',
|
||||
options: [{ label: 'Tool only', description: 'Keep the change narrow.' }, { label: 'Whole package' }],
|
||||
}, { id: 'notes', question: 'Any notes?' }],
|
||||
answers: [{ id: 'scope', selected: ['Tool only'], custom: 'note' }, { id: 'notes', selected: [] }],
|
||||
text: answered,
|
||||
time: 7_000,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('replyAnswerValues', () => {
|
||||
it('orders selected labels before a non-blank custom answer', () => {
|
||||
const data = replyData()
|
||||
expect(replyAnswerValues(data, 'scope')).toEqual(['Tool only', 'note'])
|
||||
expect(replyAnswerValues(data, 'notes')).toEqual([])
|
||||
expect(replyAnswerValues(data, 'absent')).toEqual([])
|
||||
expect(replyAnswerValues(replyData({ answers: [{ id: 'scope', selected: [], custom: ' ' }] }), 'scope')).toEqual([])
|
||||
expect(replyAnswerValues(replyData({ answers: [{ id: 'scope', selected: [], custom: 'free text' }] }), 'scope'))
|
||||
.toEqual(['free text'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('replyClipboardText', () => {
|
||||
it('pairs every question with its answer and names the questions the user skipped', () => {
|
||||
expect(replyClipboardText(replyData(), t)).toBe(
|
||||
'Scope — Which scope?\nAnswer: Tool only, note\n\nAny notes?\nSkipped',
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a header that repeats its question', () => {
|
||||
const data = replyData({
|
||||
questions: [{ id: 'scope', question: 'Which scope?', header: 'Which scope?' }],
|
||||
answers: [{ id: 'scope', selected: ['Tool only'] }],
|
||||
})
|
||||
expect(replyClipboardText(data, t)).toBe('Which scope?\nAnswer: Tool only')
|
||||
})
|
||||
|
||||
it('marks every question skipped for a dismissed reply', () => {
|
||||
const data = replyData({ outcome: 'dismissed' })
|
||||
expect(replyClipboardText(data, t)).toBe('Scope — Which scope?\nSkipped\n\nAny notes?\nSkipped')
|
||||
})
|
||||
|
||||
it('falls back to the model-facing text when the payload carried no questions', () => {
|
||||
expect(replyClipboardText(replyData({ questions: [], text: 'not json' }), t)).toBe('not json')
|
||||
})
|
||||
})
|
||||
|
||||
describe('questionReplyDefinition', () => {
|
||||
it('claims only appended user messages carrying the reply source', () => {
|
||||
expect(questionReplyDefinition.match(message(replySource('answered'), answered))).toEqual({ id: 'msg-1', role: 'start' })
|
||||
expect(questionReplyDefinition.match(message({ kind: 'user' }, answered))).toBeNull()
|
||||
expect(questionReplyDefinition.match(message({ kind: 'plugin', plugin: 'user-questions' }, answered))).toBeNull()
|
||||
expect(questionReplyDefinition.match(message(replySource('answered'), answered, null))).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['answered', 'dismissed'])('shares the message id with the generic projection for %s replies', (outcome) => {
|
||||
const event = message(replySource(outcome), outcome === 'answered' ? answered : dismissed)
|
||||
expect(messageDefinition.match(event)).toEqual({ id: 'msg-1', role: 'start' })
|
||||
expect(questionReplyDefinition.match(event)).toEqual(messageDefinition.match(event))
|
||||
})
|
||||
|
||||
it('projects the outcome from the source and the pairs from the text into its own node', () => {
|
||||
const event = message(replySource('dismissed'), dismissed)
|
||||
const state = questionReplyDefinition.start({} as never, { event, id: 'msg-1', role: 'start' } as never, {} as never)
|
||||
expect(state).toMatchObject({
|
||||
seq: 7, time: 7_000, callId: 'call-1', outcome: 'dismissed',
|
||||
questions: [{ id: 'scope', question: 'Which scope?' }], answers: [], text: dismissed,
|
||||
})
|
||||
|
||||
const node = questionReplyDefinition.buildViewNode!({ state, key: 'reply:1', id: 'msg-1', start: undefined } as never)
|
||||
expect(node).toMatchObject({
|
||||
key: 'reply:1', kind: 'question-reply', id: 'msg-1', target: 'chat', anchorSeq: 7, visibility: 'visible',
|
||||
location: { kind: 'unresolved' },
|
||||
data: { callId: 'call-1', outcome: 'dismissed', text: dismissed },
|
||||
})
|
||||
expect(node).not.toHaveProperty('data.seq')
|
||||
expect(questionReplyDefinition.update({ state } as never, {} as never)).toBe(state)
|
||||
expect(questionReplyDefinition.buildViewNode!({ state: undefined } as never)).toBeNull()
|
||||
})
|
||||
|
||||
it('starts an empty record for a message without a reply source or text', () => {
|
||||
const untyped = {
|
||||
type: 'user/message', seq: SessionSeq(8), time: 8_000, surfaceOp: 'append',
|
||||
data: { id: 'msg-2', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', source: { kind: 'inline', data: '', mediaType: 'image/png' } }] },
|
||||
} as never
|
||||
const state = questionReplyDefinition.start({} as never, { event: untyped, id: 'msg-2', role: 'start' } as never, {} as never)
|
||||
expect(state).toEqual({ seq: 8, time: 8_000, callId: '', outcome: 'answered', text: '', questions: [], answers: [] })
|
||||
})
|
||||
|
||||
it('falls back to an answered outcome when the source carries an unknown one', () => {
|
||||
const event = message({ kind: 'user-question-reply', callId: 'call-1' }, answered)
|
||||
const state = questionReplyDefinition.start({} as never, { event, id: 'msg-1', role: 'start' } as never, {} as never)
|
||||
expect(state.outcome).toBe('answered')
|
||||
expect(() => questionReplyDefinition.start({} as never, {
|
||||
event: { ...(event as object), type: 'tool/call' }, id: 'x', role: 'start',
|
||||
} as never, {} as never)).toThrow(/requires user\/message/)
|
||||
})
|
||||
})
|
||||
@@ -2,13 +2,11 @@
|
||||
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions/types'
|
||||
import { createWaterfallRequest, PendingQuestion, type QuestionCardSnapshot, type QuestionComposerProps } from '../src/client/contract/slots.ts'
|
||||
import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
|
||||
import { createQuestionDraftStore } from '../src/client/draft-store.ts'
|
||||
import { QuestionComposer as Composer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
|
||||
import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
@@ -109,7 +107,7 @@ const inputState: InputState = {
|
||||
/** Framework standard-kit stubs: the composer consumes the locale and draft-store seats;
|
||||
* the composed props type mandates delivery of the rest (framework hooks are
|
||||
* plain stubs per the client testing discipline). */
|
||||
const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'useQuestionCard' | 'actions'> = {
|
||||
const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> = {
|
||||
renderSlot: () => null,
|
||||
SessionProvider: ({ children }) => children,
|
||||
session: undefined,
|
||||
@@ -139,22 +137,10 @@ const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'useQuestion
|
||||
t: seatOver(zh, commonZh),
|
||||
}
|
||||
|
||||
let kit: Omit<QuestionComposerProps, 'matched' | 'useQuestionCard'>
|
||||
let draftInstance: ReturnType<ReturnType<typeof createQuestionDraftStore>['create']>
|
||||
|
||||
function QuestionComposer(props: Omit<QuestionComposerProps, 'useQuestionCard'>) {
|
||||
const source = props.matched
|
||||
const useQuestionCard = ((_key: string, selector?: (value: QuestionCardSnapshot | undefined) => unknown) => {
|
||||
const value = useSyncExternalStore(source.subscribe, source.getSnapshot)
|
||||
return selector === undefined ? value : selector(value)
|
||||
}) as QuestionComposerProps['useQuestionCard']
|
||||
return <Composer {...props} useQuestionCard={useQuestionCard} />
|
||||
}
|
||||
let kit: Omit<QuestionComposerProps, 'matched'>
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
const instance = createQuestionDraftStore().create(SID)
|
||||
draftInstance = instance
|
||||
const useStore: QuestionComposerProps['useStore'] = selector => useSyncExternalStore(
|
||||
listener => instance.subscribe(listener),
|
||||
() => selector(instance.getSnapshot()),
|
||||
@@ -184,34 +170,15 @@ const QUESTIONS: PendingQuestion['questions'] = [
|
||||
/** Pending waterfall fixture with observable Client response methods. */
|
||||
function wait(questions: PendingQuestion['questions'] = QUESTIONS) {
|
||||
const carrier = new PendingQuestion(SID, questions)
|
||||
const request = createWaterfallRequest(undefined, undefined, (channel) => { carrier.detachWaterfall(channel) })
|
||||
carrier.attachWaterfall(request.channel)
|
||||
const answer = vi.spyOn(carrier, 'answer')
|
||||
const dismiss = vi.spyOn(carrier, 'dismiss')
|
||||
void request.result.catch(() => {})
|
||||
return { carrier, answer, dismiss }
|
||||
const cancel = vi.spyOn(carrier, 'cancel')
|
||||
void carrier.result.catch(() => {})
|
||||
return { carrier, answer, cancel }
|
||||
}
|
||||
|
||||
const answerBatch = (answers: object[]) => ({ answers })
|
||||
|
||||
describe('QuestionComposer', () => {
|
||||
it('a blocking request shows no wait status before or after the first edit, exactly as before timed questions', () => {
|
||||
// The blocking card never carried a countdown, so there is nothing for a
|
||||
// held or frozen label to describe; the header keeps only the question and its two controls.
|
||||
const { carrier } = wait()
|
||||
const view = render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
const status = () => view.container.querySelector('[class*="waitStatus"]')
|
||||
|
||||
expect(status()).toBeNull()
|
||||
expect(screen.queryByText('会一直等你回答')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
|
||||
expect(carrier.snapshot()).toMatchObject({ waitState: 'editing', countdown: undefined })
|
||||
expect(status()).toBeNull()
|
||||
expect(screen.queryByText('会一直等你回答')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '慢慢回答' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: '放弃整组问题' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('collects single, custom, and multi-select answers before one batch submit', () => {
|
||||
const { carrier, answer } = wait()
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
@@ -374,8 +341,8 @@ describe('QuestionComposer', () => {
|
||||
})
|
||||
|
||||
it('surfaces cancellation failures and re-arms the controls', async () => {
|
||||
const { carrier, dismiss } = wait()
|
||||
dismiss
|
||||
const { carrier, cancel } = wait()
|
||||
cancel
|
||||
.mockRejectedValueOnce(new Error('第一次取消失败'))
|
||||
.mockRejectedValueOnce(new Error('第二次取消失败'))
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
@@ -446,6 +413,26 @@ describe('QuestionComposer', () => {
|
||||
})
|
||||
|
||||
describe('PendingQuestion domain face', () => {
|
||||
it('resolves the waterfall result with the answer batch and settles once', async () => {
|
||||
const question = new PendingQuestion(SID, QUESTIONS)
|
||||
const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] }
|
||||
await expect(question.answer(batch)).resolves.toBeUndefined()
|
||||
await expect(question.result).resolves.toBe(batch)
|
||||
await expect(question.answer(batch)).rejects.toThrow(/already settled/)
|
||||
})
|
||||
|
||||
it('rejects the waterfall result with ASK_CANCELLED and settles once', async () => {
|
||||
const question = new PendingQuestion(SID, QUESTIONS)
|
||||
const result = question.result.catch((error: unknown) => error)
|
||||
await expect(question.cancel()).resolves.toBeUndefined()
|
||||
await expect(result).resolves.toMatchObject({
|
||||
name: 'UserQuestionError',
|
||||
code: 'ASK_CANCELLED',
|
||||
message: 'the user cancelled ask_user_question',
|
||||
})
|
||||
await expect(question.cancel()).rejects.toThrow(/already settled/)
|
||||
})
|
||||
|
||||
it('exposes its Client render identity and scoped request values', () => {
|
||||
const question = new PendingQuestion(SID, QUESTIONS)
|
||||
expect(question.key).toMatch(/^question:\d+$/)
|
||||
@@ -501,338 +488,3 @@ describe('parseRecommendedLabel', () => {
|
||||
expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
|
||||
})
|
||||
})
|
||||
|
||||
const TIMED: PendingQuestion['questions'] = [{
|
||||
id: 'scope', question: '选择范围', options: [{ label: '仅工具' }, { label: '全部' }],
|
||||
}]
|
||||
|
||||
/** A timed card as the Remote Event listener builds it: waterfall channel with a Client-decided deadline. */
|
||||
function timedCard(deadline: number, callId = ToolCallId('ask-timed')) {
|
||||
const carrier = new PendingQuestion(SID, TIMED, callId)
|
||||
const request = createWaterfallRequest(deadline, undefined, (channel) => { carrier.detachWaterfall(channel) })
|
||||
carrier.attachWaterfall(request.channel)
|
||||
void request.result.catch(() => {})
|
||||
return { carrier, request }
|
||||
}
|
||||
|
||||
describe('timed card', () => {
|
||||
it.each([true, false])('does not autofocus before claiming and respects manual focus until readiness: %s', async (stillFocused) => {
|
||||
vi.useFakeTimers()
|
||||
const carrier = new PendingQuestion(SID, [{ id: 'free', question: '补充说明' }], ToolCallId('claiming'))
|
||||
const request = createWaterfallRequest(Date.now() + 2_000, undefined,
|
||||
(channel) => { carrier.detachWaterfall(channel) })
|
||||
try {
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
const field = screen.getByPlaceholderText(zh['custom.placeholder'])
|
||||
expect(document.activeElement).not.toBe(field)
|
||||
|
||||
fireEvent.focus(field)
|
||||
if (!stillFocused) fireEvent.blur(field, { relatedTarget: document.body })
|
||||
act(() => { carrier.attachWaterfall(request.channel) })
|
||||
expect(carrier.snapshot().countdown).toEqual({ remainingMs: 2_000, running: !stillFocused })
|
||||
if (stillFocused) {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(carrier.snapshot().channel).toBe('waterfall')
|
||||
fireEvent.blur(field, { relatedTarget: document.body })
|
||||
expect(carrier.snapshot().countdown).toEqual({ remainingMs: 2_000, running: true })
|
||||
}
|
||||
} finally {
|
||||
act(() => { request.channel.resolve({ answers: [{ id: 'free', selected: [] }] }) })
|
||||
await request.result
|
||||
cleanup()
|
||||
carrier.close()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('pauses a pristine countdown while the answer surface has focus and resumes its remainder on blur', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { carrier, request } = timedCard(Date.now() + 2_000)
|
||||
let settled = false
|
||||
void request.result.then(() => { settled = true }, () => { settled = true })
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
const option = screen.getByRole('radio', { name: '仅工具' })
|
||||
|
||||
fireEvent.focus(option)
|
||||
expect(carrier.snapshot()).toMatchObject({ waitState: 'focused', countdown: { running: false } })
|
||||
expect(screen.getByText(/已暂停/)).toBeTruthy()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(settled).toBe(false)
|
||||
|
||||
fireEvent.blur(option, { relatedTarget: document.body })
|
||||
expect(carrier.snapshot()).toMatchObject({ waitState: 'counting' })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_100) })
|
||||
expect(settled).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the first edited draft indefinite and restores that choice after remount', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const first = timedCard(Date.now() + 1_000)
|
||||
let firstSettled = false
|
||||
void first.request.result.then(() => { firstSettled = true }, () => { firstSettled = true })
|
||||
const view = render(<QuestionComposer matched={first.carrier} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: '仅工具' }))
|
||||
expect(first.carrier.snapshot()).toMatchObject({ waitState: 'editing', countdown: { running: false } })
|
||||
expect(draftInstance.getSnapshot().progressByRequest[first.carrier.key]?.wait).toBe('editing')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(firstSettled).toBe(false)
|
||||
|
||||
view.unmount()
|
||||
const restored = timedCard(Date.now() + 1_000)
|
||||
let restoredSettled = false
|
||||
void restored.request.result.then(() => { restoredSettled = true }, () => { restoredSettled = true })
|
||||
render(<QuestionComposer matched={restored.carrier} {...kit} />)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(restored.carrier.snapshot()).toMatchObject({ waitState: 'editing', countdown: { running: false } })
|
||||
expect(screen.getByText('会一直等你回答')).toBeTruthy()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(restoredSettled).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('counts down locally, settles the waterfall with ASK_TIMED_OUT at zero, and waits for the projection', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { carrier, request } = timedCard(Date.now() + 1_500)
|
||||
const rejection = expect(request.result).rejects.toMatchObject({ name: 'UserQuestionError', code: 'ASK_TIMED_OUT' })
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
expect(screen.getByText(/秒后继续工作/)).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '慢慢回答' })).toBeTruthy()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_100) })
|
||||
|
||||
await rejection
|
||||
expect(carrier.snapshot()).toEqual({
|
||||
state: 'open', waitState: 'counting', countdown: undefined,
|
||||
channel: 'none', closed: false,
|
||||
})
|
||||
expect(screen.queryByText(/秒后继续工作/)).toBeNull()
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(true)
|
||||
|
||||
const answer = vi.fn(async () => true)
|
||||
act(() => {
|
||||
carrier.attachRpc({ answer })
|
||||
carrier.setState('continued')
|
||||
})
|
||||
expect(screen.getByText('已继续工作,仍可回答')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('radio', { name: '仅工具' }))
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(answer).toHaveBeenCalledWith({ answers: [{ id: 'scope', selected: ['仅工具'] }] })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets the user cancel this countdown and keep the request blocking', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { carrier, request } = timedCard(Date.now() + 1_000)
|
||||
let settled = false
|
||||
void request.result.then(() => { settled = true }, () => { settled = true })
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '慢慢回答' }))
|
||||
|
||||
expect(screen.queryByText(/秒后继续工作/)).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '慢慢回答' })).toBeNull()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(settled).toBe(false)
|
||||
expect(carrier.snapshot()).toMatchObject({ channel: 'waterfall', waitState: 'waiting' })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-arms the controls with a resubmit hint when a sent answer is dropped', async () => {
|
||||
const { carrier, request } = timedCard(Date.now() + 60_000)
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
fireEvent.click(screen.getByRole('radio', { name: '仅工具' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
|
||||
await expect(request.result).resolves.toEqual({ answers: [{ id: 'scope', selected: ['仅工具'] }] })
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
|
||||
expect(draftInstance.getSnapshot().progressByRequest[carrier.key]).toBeDefined()
|
||||
|
||||
act(() => { carrier.setState('continued') })
|
||||
|
||||
expect(screen.getByText('回答未送达,工作已继续,请再提交一次。')).toBeTruthy()
|
||||
expect(screen.getByRole('radio', { name: '仅工具' }).getAttribute('aria-checked')).toBe('true')
|
||||
const answer = vi.fn(async () => true)
|
||||
act(() => { carrier.attachRpc({ answer }) })
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
await vi.waitFor(() => { expect(answer).toHaveBeenCalledWith({ answers: [{ id: 'scope', selected: ['仅工具'] }] }) })
|
||||
})
|
||||
|
||||
it('closing a continued panel only withdraws it from the seat and sends nothing', async () => {
|
||||
const carrier = new PendingQuestion(SID, TIMED, ToolCallId('ask-continued'))
|
||||
const answer = vi.fn(async () => true)
|
||||
const hide = vi.fn()
|
||||
carrier.attachRpc({ answer })
|
||||
carrier.attachSeat({ hide })
|
||||
carrier.setState('continued')
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
expect(screen.getByText('已继续工作,仍可回答')).toBeTruthy()
|
||||
expect(screen.queryByText(/秒后继续工作/)).toBeNull()
|
||||
// The close button names the reopen path, not a dismissal of the question.
|
||||
expect(screen.queryByLabelText('放弃整组问题')).toBeNull()
|
||||
fireEvent.click(screen.getByLabelText('收起问题面板,可从工具调用重新打开'))
|
||||
|
||||
await vi.waitFor(() => { expect(hide).toHaveBeenCalledOnce() })
|
||||
expect(answer).not.toHaveBeenCalled()
|
||||
expect(carrier.snapshot()).toMatchObject({ state: 'continued', channel: 'rpc', closed: false })
|
||||
})
|
||||
|
||||
it('closing a card-keyed panel with no channel left still just withdraws it', () => {
|
||||
const carrier = new PendingQuestion(SID, TIMED, ToolCallId('ask-gap'))
|
||||
const hide = vi.fn()
|
||||
carrier.attachSeat({ hide })
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('收起问题面板,可从工具调用重新打开'))
|
||||
|
||||
expect(hide).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByText('当前无法提交,请稍候再试。')).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an unavailable channel instead of cancelling into the gap', () => {
|
||||
// A request the Host never named: closing it is the cancellation, so with
|
||||
// no channel to carry the rejection there is nothing to do but say so.
|
||||
const carrier = new PendingQuestion(SID, TIMED)
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('放弃整组问题'))
|
||||
|
||||
expect(screen.getByText('当前无法提交,请稍候再试。')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('prunes drafts no card owns on mount and clears its own draft when the card closes', () => {
|
||||
kit.actions.replace('question:stale', { index: 0, drafts: [{ selected: [], custom: 'old', skipped: false }] })
|
||||
const { carrier } = timedCard(Date.now() + 60_000)
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
expect(draftInstance.getSnapshot().progressByRequest['question:stale']).toBeUndefined()
|
||||
fireEvent.click(screen.getByRole('radio', { name: '全部' }))
|
||||
expect(draftInstance.getSnapshot().progressByRequest[carrier.key]?.drafts[0]?.selected).toEqual(['全部'])
|
||||
|
||||
act(() => { carrier.close() })
|
||||
|
||||
expect(draftInstance.getSnapshot().progressByRequest[carrier.key]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
const REVIEWED_CALL = 'ask-answered'
|
||||
/** One settled call's answers in record order, which is not the question order. */
|
||||
const RECORDED: readonly AskUserQuestionAnswerItem[] = [
|
||||
{ id: 'signals', selected: [] },
|
||||
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
|
||||
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
|
||||
]
|
||||
/** A record whose multi-select answer used the custom row beside a checked option. */
|
||||
const RECORDED_CUSTOM: readonly AskUserQuestionAnswerItem[] = [
|
||||
{ id: 'profile', selected: ['研究潜力型'] },
|
||||
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
|
||||
{ id: 'signals', selected: ['系统设计'], custom: '沟通能力' },
|
||||
]
|
||||
|
||||
/** A read-only card as the panel provider builds it from a settled call's transcript record. */
|
||||
function reviewCard(review: readonly AskUserQuestionAnswerItem[] = RECORDED) {
|
||||
const carrier = new PendingQuestion(SID, QUESTIONS, ToolCallId(REVIEWED_CALL), undefined, review)
|
||||
const hide = vi.fn()
|
||||
carrier.attachSeat({ hide })
|
||||
return { carrier, hide }
|
||||
}
|
||||
|
||||
describe('review card', () => {
|
||||
it('walks a settled call record with every answer surface frozen', () => {
|
||||
const { carrier } = reviewCard()
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
expect(screen.getByText(zh['review.status'])).toBeTruthy()
|
||||
// The recorded selection is paired by question id, not by record order.
|
||||
const chosen = screen.getByRole<HTMLButtonElement>('radio', { name: '工程落地型' })
|
||||
expect(chosen.getAttribute('aria-checked')).toBe('true')
|
||||
expect(chosen.disabled).toBe(true)
|
||||
// Nothing is left to send, and the unused free-text field would read as
|
||||
// somewhere to type.
|
||||
expect(screen.queryByRole('button', { name: '跳过' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '提交' })).toBeNull()
|
||||
expect(screen.queryByPlaceholderText('输入你的答案')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('下一题'))
|
||||
|
||||
expect(screen.getByText('2 / 3')).toBeTruthy()
|
||||
const optionless = screen.getByPlaceholderText<HTMLTextAreaElement>('输入你的答案')
|
||||
expect(optionless.value).toBe('要能独立排查线上问题')
|
||||
expect(optionless.disabled).toBe(true)
|
||||
expect(document.activeElement).not.toBe(optionless)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('下一题'))
|
||||
|
||||
expect(screen.getByText('3 / 3')).toBeTruthy()
|
||||
expect(screen.getByText(zh['review.skipped'])).toBeTruthy()
|
||||
expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('false')
|
||||
expect(screen.queryByPlaceholderText('输入你的答案')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the record back instead of a draft the live card left under the same key', () => {
|
||||
kit.actions.replace(PendingQuestion.keyOf(SID, REVIEWED_CALL), {
|
||||
index: 2,
|
||||
drafts: [
|
||||
{ selected: [], custom: '没提交的草稿', skipped: false },
|
||||
{ selected: [], custom: '', skipped: false },
|
||||
{ selected: ['代码质量'], custom: '', skipped: false },
|
||||
],
|
||||
})
|
||||
const { carrier } = reviewCard(RECORDED_CUSTOM)
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
// The page the live card was on is restored; its half-typed answers are not.
|
||||
expect(screen.getByText('3 / 3')).toBeTruthy()
|
||||
expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true')
|
||||
expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('false')
|
||||
const custom = screen.getByPlaceholderText<HTMLTextAreaElement>('输入你的答案')
|
||||
expect(custom.value).toBe('沟通能力')
|
||||
expect(custom.disabled).toBe(true)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('上一题'))
|
||||
fireEvent.click(screen.getByLabelText('上一题'))
|
||||
|
||||
expect(screen.getByRole('radio', { name: '研究潜力型' }).getAttribute('aria-checked')).toBe('true')
|
||||
expect(screen.queryByDisplayValue('没提交的草稿')).toBeNull()
|
||||
})
|
||||
|
||||
it('closing a read-only panel withdraws it and sends nothing', () => {
|
||||
const { carrier, hide } = reviewCard()
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
// The card is keyed by its tool call, so closing names the reopen path.
|
||||
expect(screen.queryByLabelText(zh['nav.cancel'])).toBeNull()
|
||||
fireEvent.click(screen.getByLabelText(zh['nav.close']))
|
||||
|
||||
expect(hide).toHaveBeenCalledOnce()
|
||||
expect(carrier.snapshot()).toEqual({
|
||||
state: 'open', waitState: 'counting', countdown: undefined,
|
||||
channel: 'none', closed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the read-only copy through the English dictionary', () => {
|
||||
const { carrier } = reviewCard()
|
||||
render(<QuestionComposer matched={carrier} {...kit} t={seatOver(en, commonEn)} />)
|
||||
|
||||
expect(screen.getByText(en['review.status'])).toBeTruthy()
|
||||
fireEvent.click(screen.getByLabelText(en['nav.next']))
|
||||
fireEvent.click(screen.getByLabelText(en['nav.next']))
|
||||
expect(screen.getByText(en['review.skipped'])).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -32,9 +32,6 @@
|
||||
{
|
||||
"path": "../store"
|
||||
},
|
||||
{
|
||||
"path": "../ui-chat/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation/tsconfig.client.json"
|
||||
},
|
||||
@@ -50,14 +47,8 @@
|
||||
{
|
||||
"path": "../ui-session"
|
||||
},
|
||||
{
|
||||
"path": "../ui-tool"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
'useProjection: UseProjection',
|
||||
'useTrajectory: UseTrajectory',
|
||||
],
|
||||
keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, question-reply, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, turn-trigger, unknown, user, workflow-run',
|
||||
keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, turn-trigger, unknown, user, workflow-run',
|
||||
hookContext: 'ChatNodeHookContext',
|
||||
slotInject: 'ChatNodeInjected',
|
||||
declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted',
|
||||
@@ -290,7 +290,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
'client-ui-chat UnknownNodeView key \'unknown\'',
|
||||
'client-ui-goal GoalCommandInputView key \'command-input\'',
|
||||
'client-ui-tool ToolCallTree key \'tool-call\'',
|
||||
'client-ui-user-questions QuestionReplyView key \'question-reply\'',
|
||||
'client-ui-workflow-run WorkflowRunPanel key \'workflow-run\'',
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
@@ -3971,7 +3970,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.images\', () => ctx.slots.register(\n { name: \'tool.call.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-tool/src/client/contract/slots.ts:46',
|
||||
source: 'packages/client/ui-tool/src/client/contract/slots.ts:45',
|
||||
},
|
||||
{
|
||||
key: 'tool.call.toolview',
|
||||
@@ -4079,7 +4078,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-tool/src/client/contract/slots.ts:26',
|
||||
source: 'packages/client/ui-tool/src/client/contract/slots.ts:25',
|
||||
},
|
||||
{
|
||||
key: 'tool.view.cordis',
|
||||
|
||||
@@ -3352,26 +3352,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
summary: '`ctx.userQuestions`: validation plus the scoped answerer waterfall.',
|
||||
description: '`ctx.userQuestions`: validation plus the scoped answerer waterfall.',
|
||||
methods: [
|
||||
{
|
||||
signature: '@Remote answer(agent: Agent, callId: ToolCallId, answer: AskUserQuestionAnswer): boolean',
|
||||
description: 'Answer a continued question. The reply is steered into the agent as a user message whose source names the call; that message is also the record that closes the question in the projection.',
|
||||
parameters: [{ name: 'agent', description: 'Live root agent for the owning Session.' }, { name: 'callId', description: 'Continued question identity.' }, { name: 'answer', description: 'Complete structured answer batch, one item per question of the call.' }],
|
||||
returns: 'Whether the question was continued and accepted the answer.',
|
||||
throws: ['{UserQuestionError} `BAD_ANSWER` when the batch does not name each question of the call exactly once.'],
|
||||
},
|
||||
{
|
||||
signature: '@Remote({ mode: \'stream\' }) async *attachWait(agent: Agent, callId: ToolCallId, signal: AbortSignal): AsyncIterable<{ remainingMs: number }>',
|
||||
description: 'Let one answer UI hold a live timed wait. Closing the stream releases its claim.',
|
||||
parameters: [{ name: 'agent', description: 'Live root agent owning the question.' }, { name: 'callId', description: 'Foreground tool call to attach to.' }, { name: 'signal', description: 'Remote stream cancellation, including Client disconnect.' }],
|
||||
returns: 'One Host-computed remaining duration, or no frames once the wait ended.',
|
||||
},
|
||||
{
|
||||
signature: 'async askTimed( request: AskUserQuestionRequest & { agent: Agent }, callId: ToolCallId, timeoutMs: number, ): Promise<TimedUserQuestionResult>',
|
||||
description: 'Foreground wait whose first settlement the Client decides: the Client rejects with `ASK_TIMED_OUT` when its countdown ends, and this method maps that code to the pending result.',
|
||||
parameters: [{ name: 'request', description: 'Questions, live owner agent, and abort signal.' }, { name: 'callId', description: 'Tool call identity the Client card is keyed by.' }, { name: 'timeoutMs', description: 'Positive foreground wait in milliseconds.' }],
|
||||
returns: 'The answer when it arrives inside the window, otherwise a pending result, also when no connected Client claimed the request by the deadline.',
|
||||
throws: ['{UserQuestionError} `BAD_TIMEOUT` for a non-integer, non-positive, or oversized wait.'],
|
||||
},
|
||||
{
|
||||
signature: 'async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>',
|
||||
description: 'Ask the scoped answerer waterfall and wait for the user\'s answer.\n\nWhen a caller supplies an agent, human interaction is valid only for the exact live runtime root. Runtime ownership, not durable session lineage, decides this boundary: an owned child has no human answerer and would block forever, while a lineage-bearing session resumed as a new runtime root may ask normally.',
|
||||
@@ -4519,7 +4499,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'AskUserQuestionRequestEvent',
|
||||
declaration: 'export interface AskUserQuestionRequestEvent {\n questions: AskUserQuestionItem[];\n agent?: Agent;\n signal?: AbortSignal;\n wait?: {\n callId: ToolCallId;\n timed?: boolean;\n };\n}',
|
||||
declaration: 'export interface AskUserQuestionRequestEvent {\n questions: AskUserQuestionItem[];\n agent?: Agent;\n signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AssembleContext',
|
||||
@@ -7461,10 +7441,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'TerminalWaitReason',
|
||||
declaration: 'export type TerminalWaitReason = \'stdin_read\' | \'inferred_idle\' | \'timeout\' | \'session_exit\';',
|
||||
},
|
||||
{
|
||||
name: 'TimedUserQuestionResult',
|
||||
declaration: 'export type TimedUserQuestionResult = AskUserQuestionAnswer | {\n pending: true;\n callId: ToolCallId;\n};',
|
||||
},
|
||||
{
|
||||
name: 'TimeOutOfRangeError',
|
||||
declaration: 'export interface TimeOutOfRangeError {\n readonly code: \'time_out_of_range\';\n readonly message: string;\n}',
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: ccceebf8138e3e16
|
||||
zh: fcb20d56fed52173
|
||||
/interaction-the-human-collaboration-plane/related-documentation:
|
||||
en: 52ab94f72db8979b
|
||||
zh: 1f9abbaaf2618f7a
|
||||
en: cccdb31d52ad2938
|
||||
zh: d63855e30e60cc6d
|
||||
/interaction-the-human-collaboration-plane/dev-note:
|
||||
en: 5de1d6304e00c58f
|
||||
zh: 8d8225361cfa29e5
|
||||
@@ -42,7 +42,7 @@ Start with the subsystem references for the shared vocabularies, then the neighb
|
||||
- [Commands subsystem](../../docs/subsystems/commands.md) — command registry semantics and the `ctx.commands` Cordis surface.
|
||||
- [Approval subsystem](../../docs/subsystems/approval.md) — request/outcome vocabulary, the answerer waterfall, and per-session policy.
|
||||
- [Permission presets subsystem](../../docs/subsystems/permission-presets.md) — the preset table and the knob write-through.
|
||||
- [User interaction subsystem](../../docs/subsystems/user-questions.md) — question vocabulary, answerer waterfall, and pending-question lifecycle.
|
||||
- [User interaction subsystem](../../docs/subsystems/user-questions.md) — question vocabulary, answerer waterfall, and presentation intent.
|
||||
- [ACP group](../acp/README.md) — the automation-only transport that answers approval requests for its own agents.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -42,7 +42,7 @@ kind: "package-group"
|
||||
- [命令子系统](../../docs/subsystems/commands.zh.md)——命令注册表语义与 `ctx.commands` 的 Cordis 接口面。
|
||||
- [审批子系统](../../docs/subsystems/approval.zh.md)——请求/结果词汇、应答者瀑布与按会话策略。
|
||||
- [权限预设子系统](../../docs/subsystems/permission-presets.zh.md)——预设表与旋钮写穿。
|
||||
- [用户交互子系统](../../docs/subsystems/user-questions.zh.md)——问题词汇、answerer waterfall 与待处理问题生命周期。
|
||||
- [用户交互子系统](../../docs/subsystems/user-questions.zh.md)——问题词汇、answerer waterfall 与呈现意图。
|
||||
- [ACP 组](../acp/README.zh.md)——仅自动化的传输,为其自有 agent 回答审批请求。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -9,32 +9,32 @@
|
||||
en: 2a561a70c4a900d7
|
||||
zh: 3d12372992cf2634
|
||||
/deepseek-ai-dsh-tool-ask-user/summary:
|
||||
en: e6399064daafe79d
|
||||
zh: c033d2569b92807e
|
||||
en: 45bb29d463d1e8d2
|
||||
zh: a462610ab6e8e61b
|
||||
/deepseek-ai-dsh-tool-ask-user/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-tool-ask-user/use-this-package:
|
||||
en: dd775f906b7e5d94
|
||||
zh: 41c9507ff27727cd
|
||||
en: ed8ec46772509d66
|
||||
zh: b06aad135f7a4fa3
|
||||
/deepseek-ai-dsh-tool-ask-user/use-this-package/when-to-call-the-tool:
|
||||
en: e31391fd6eb756a2
|
||||
zh: b96abf574d32c4e2
|
||||
en: 5a142d88def1a4fd
|
||||
zh: 2ae5573abf23d76a
|
||||
/deepseek-ai-dsh-tool-ask-user/use-this-package/what-the-model-gets-back:
|
||||
en: 00a1aee88f9c6e4a
|
||||
zh: 95090cbfce6f2f03
|
||||
en: d0047cb9dda88606
|
||||
zh: d3432561db23f65a
|
||||
/deepseek-ai-dsh-tool-ask-user/use-this-package/when-the-call-fails:
|
||||
en: 6911218ee3ce7080
|
||||
zh: fddd5df4cbc75330
|
||||
en: dd379ed6282761b6
|
||||
zh: fa7da19a52091403
|
||||
/deepseek-ai-dsh-tool-ask-user/understand-the-implementation:
|
||||
en: e9b9d46be0b45ca2
|
||||
zh: c02403486473cd80
|
||||
/deepseek-ai-dsh-tool-ask-user/understand-the-implementation/source-map:
|
||||
en: b36d487d91fc25fa
|
||||
zh: ea071f6928236e0f
|
||||
en: 8ce163d8203be215
|
||||
zh: 6fd9db8f061b8198
|
||||
/deepseek-ai-dsh-tool-ask-user/understand-the-implementation/consumer-role:
|
||||
en: 39e0317e7abc2f64
|
||||
zh: 7350d58e8f428763
|
||||
en: 30c2b751e1b04fe4
|
||||
zh: 862c1087ad9c8f66
|
||||
/deepseek-ai-dsh-tool-ask-user/understand-the-implementation/result-rendering:
|
||||
en: 686176b2703dfa08
|
||||
zh: 4ea4dab0da7f79f9
|
||||
@@ -48,8 +48,8 @@
|
||||
en: e4c7376d4f29dbd5
|
||||
zh: 6b7a9eaf69c9ea53
|
||||
/deepseek-ai-dsh-tool-ask-user/model-experience/tool-schema/what-the-model-sees:
|
||||
en: 577c8609a8bb915d
|
||||
zh: 8607e0d39be0e492
|
||||
en: e043611dc8d99927
|
||||
zh: 2e4b0e6548f2d64c
|
||||
/deepseek-ai-dsh-tool-ask-user/model-experience/tool-schema/token-effect:
|
||||
en: 97cce067584eadff
|
||||
zh: c376cab39ea9df68
|
||||
@@ -60,8 +60,8 @@
|
||||
en: 9717b983b6a857e3
|
||||
zh: 611a2dadf7c0c706
|
||||
/deepseek-ai-dsh-tool-ask-user/model-experience/tool-call-history-and-result/what-the-model-sees:
|
||||
en: 794deb84a3280243
|
||||
zh: 7a90348b2826d44e
|
||||
en: d5ba86e2dbdb5b4b
|
||||
zh: f5a2d6de0d985eed
|
||||
/deepseek-ai-dsh-tool-ask-user/model-experience/tool-call-history-and-result/token-effect:
|
||||
en: 3092d4afd4ba8678
|
||||
zh: cb7a7bbefdbc3a1e
|
||||
@@ -69,8 +69,8 @@
|
||||
en: f5d5f97a153f7c70
|
||||
zh: 4db81881ebdcfcc4
|
||||
/deepseek-ai-dsh-tool-ask-user/known-limitations-and-deferred-work:
|
||||
en: 0f3dbde4903c773a
|
||||
zh: c878f294a63ccde6
|
||||
en: a0f0f15f51d5affb
|
||||
zh: 41f11351e852968c
|
||||
/deepseek-ai-dsh-tool-ask-user/known-limitations-and-deferred-work/dev-note:
|
||||
en: a587c64d086a9c70
|
||||
zh: a6bbf51ef703479a
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`ask_user_question` asks the human for confirmation, a choice, or missing information. By default it keeps the original blocking legacy behavior unchanged: the model waits for an answer. The asynchronous timed variant is an explicit Cordis opt-in with `mode: timed`; it waits for a foreground window, then returns a pending result so the model can continue independent work while the question stays answerable. Within timed mode, `timeout: -1` requests an indefinite blocking wait for that call. A live child agent owned by another agent cannot call this tool. The package renders nothing; callers provide the user interaction surface.
|
||||
`ask_user_question` lets a model pause work and ask the human for confirmation, a choice, or missing information. It accepts one or more questions and returns their answers as compact JSON. The call waits until an answer is accepted or the turn is cancelled; if no answer handler accepts it, the model receives an error. A live child agent owned by another agent cannot call this tool and must report unresolved questions in its final result. The package does not render or collect input, so callers must provide a compatible user interaction surface.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -27,21 +27,9 @@ English | [中文](README.zh.md)
|
||||
|
||||
Compose this plugin wherever the model should be able to pause for a human decision: it provides the `ask_user_question` tool and needs the `ctx.userQuestions` seam with an answerer that accepts the scoped request. Without one, the tool call fails with an error instead of degrading.
|
||||
|
||||
Shipped presets omit config and therefore expose the original blocking legacy tool. To switch on the alternate asynchronous behavior, set `mode: timed` on the `tool-ask-user` row inside the active preset's `config.plugins` list:
|
||||
|
||||
```yaml
|
||||
- id: tool-ask-user
|
||||
name: '@deepseek-ai/dsh-tool-ask-user'
|
||||
config:
|
||||
mode: timed
|
||||
timeout: 120
|
||||
```
|
||||
|
||||
This snippet is a plugin row, not a top-level `--patch` entry. The shipped Web profile nests it inside `preset-standard`, so a Web profile patch must update that preset's plugin list, or the same setting can be changed in the Agent Preset editor. Use `mode: legacy` (or omit `config`) to keep the blocking schema. In timed mode, the row's `timeout` is the default for every tool call. Setting it to `-1` makes every call wait indefinitely unless the model passes a positive `timeout` argument. A `timeout: -1` argument changes only that tool call, which may contain several questions; one answer batch settles the call.
|
||||
|
||||
### When to call the tool
|
||||
|
||||
The model calls `ask_user_question` when it needs confirmation, a choice, or missing information before proceeding. Send one or more questions, each with an `id` unique within that call and echoed in the answer; separate calls may reuse an id because their call IDs distinguish them. A recommended option goes first with `(Recommended)` appended to its label. The optional per-call `timeout` is measured in seconds; `-1` makes the new tool block when work cannot safely proceed without an answer. A timeout is never approval. The Web card also lets a user explicitly take time or begin editing, which holds that Client's pending wait indefinitely until the answer is submitted or the call is cancelled. Choosing the complete old tool is a Cordis composition decision, not a model argument.
|
||||
The model calls `ask_user_question` when it needs confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable `id` that is echoed in the answer; a recommended option goes first with `(Recommended)` appended to its label.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -61,7 +49,7 @@ The model calls `ask_user_question` when it needs confirmation, a choice, or mis
|
||||
|
||||
### What the model gets back
|
||||
|
||||
An answer received before the timeout returns one answer object per question: `selected` holds the chosen option labels, and `custom` carries a free-form answer — supplementing `selected` for a multi-select question and overriding it for a single-select question. An explicitly skipped question is a completed answer item with empty `selected` and no `custom`. By contrast, `{ "pending": true, "callId": "…" }` means no answer batch arrived before the initial wait expired; the questions remain answerable, the model continues only independent work, and a later answer is delivered as an ordinary user message whose `kind`, `tool`, and `callId` identify the earlier pending tool call. The Web chat presents that payload as the original questions paired with their answers; other consumers retain the compact JSON text.
|
||||
The tool returns one answer object per question: `selected` holds the chosen option labels, and `custom` carries a free-form answer — supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape.
|
||||
|
||||
```json
|
||||
{ "answers": [{ "id": "cleanup", "selected": ["Yes, delete them (Recommended)"] }] }
|
||||
@@ -69,7 +57,7 @@ An answer received before the timeout returns one answer object per question: `s
|
||||
|
||||
### When the call fails
|
||||
|
||||
In the default legacy mode the tool call always waits for the human answer. With `mode: timed`, the alternate tool blocks until the human answers, its timeout expires, or the turn is cancelled; `timeout: -1` keeps that timed call blocking indefinitely. No accepting answerer, an aborted call, or a caller that is not the exact live runtime root each settles as an error the model sees in the tool result — most notably, a live child agent owned by another agent is rejected (`DELEGATED_CALLER`) and must include the unresolved question or decision in its final result.
|
||||
The tool call blocks until the human answers and cancels only through the turn's signal. No accepting answerer, an aborted call, or a caller that is not the exact live runtime root each settles as an error the model sees in the tool result — most notably, a live child agent owned by another agent is rejected (`DELEGATED_CALLER`) and must include the unresolved question or decision in its final result.
|
||||
|
||||
-----
|
||||
|
||||
@@ -86,12 +74,11 @@ The observable behavior is covered in [Use this package](#use-this-package); thi
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Tool registration: `ask_user_question` schema, execute path, result render |
|
||||
| [`src/legacy.ts`](src/legacy.ts) | Frozen pre-timeout description, arguments, output, and blocking execution |
|
||||
| — | No runtime invariant companion is published; this model-facing adapter has no independent lifecycle stream; execution relations are owned by the capability seam it calls. |
|
||||
|
||||
### Consumer role
|
||||
|
||||
The plugin registers exactly one `defineTool` entry on `ctx.tools` with injects `['tools', 'userQuestions']`. The default mode registers the original legacy tool verbatim, as a frozen copy in `src/legacy.ts` that shares no schema or mapping code with the timed tool, and routes every call through blocking `ask()`. Setting the Cordis config to `mode: timed` registers the alternate timed schema and routes positive timeouts through `askTimed()` while `-1` routes through blocking `ask()`. The two definitions never appear together. Both forward the exact calling agent and turn signal; the seam owns identity checks, waterfall dispatch, and the error taxonomy. Every timed-mode request names the call in `wait`, including the indefinite `-1` form, so a Client can key its question surface to this tool call; the legacy request stays unkeyed. The timed schema's `timeout` parameter is also what the `userQuestions` projection reads out of the logged request header to tell a timed call from a legacy one.
|
||||
The plugin registers one `defineTool` entry on `ctx.tools` with injects `['tools', 'userQuestions']`. `execute` maps model arguments into an `AskUserQuestionRequest`, forwards the exact calling agent and the turn's signal, and maps the accepted answer back into the canonical `answers` array. The seam owns identity checks, intent validation, waterfall dispatch, and the error taxonomy; this package only translates.
|
||||
|
||||
### Result rendering
|
||||
|
||||
@@ -120,7 +107,7 @@ Read these pages when the package-level contract is not enough. They move from t
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The shipped presets expose the original blocking [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). A custom Cordis row with `mode: timed` switches to the alternate schema, including question ids, prompts, headings, options, multi-select flags, `timeout`, and the pending result; the model sees only the selected definition.
|
||||
The model sees the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user), including question ids, prompts, headings, options, and multi-select flags.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -134,7 +121,7 @@ Prefix-stable while the definition and visibility are unchanged. Plugin lifecycl
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model's full questions remain in the assistant tool-call arguments. An answer inside the foreground wait appears in the next step as compact JSON in the exact shape `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}`; `custom` is omitted when unused and `selected` can contain zero, one, or several labels. A pending step reads as one compact `{"pending":true,"callId":"<pending-call-id>","message":"<instruction>"}` object, whose `message` directs the model to continue independent work; the instruction is a field rather than prose beside the object because the recorded result text is also read back as one JSON object by the question projection and the Web card. After a pending result, the eventual answer is a user message in the shape `{"kind":"answer_to_pending_question","tool":"ask_user_question","callId":"<pending-call-id>","questions":[...],"answers":[...]}`. The discriminator and call id explicitly tell the model that this answers an earlier pending call, while the repeated questions keep the answer self-contained. UI interaction while the call is pending is not model context.
|
||||
The model's full questions remain in the assistant tool-call arguments. After the human answers, the next step sees compact JSON in the exact shape `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}`; `custom` is omitted when unused and `selected` can contain zero, one, or several labels. UI interaction while the call is pending is not model context.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -151,7 +138,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
These limits define when the tool is a poor fit. They are current package constraints, not a UI backlog.
|
||||
|
||||
- **The legacy tool never reports pending** — `mode: legacy` keeps the blocking in-memory wait and returns only an answer or an error. Its calls never enter the `userQuestions` projection, because the request header records the legacy schema, so a legacy call interrupted by process loss shows no continued question and takes no late answer, exactly as before timed questions existed.
|
||||
- **A pending question blocks the tool call until the human answers** — the tool declares no `timeout-policy` budget; cancellation rides the turn's `exec.signal` only.
|
||||
- **Runtime-owned subagents cannot ask the user** — `ask_user_question` rejects a live child owned by another agent with `DELEGATED_CALLER`; the child must include the unresolved question or decision in its final result. Durable lineage does not decide this boundary, so a lineage-bearing session resumed as a runtime root may ask normally.
|
||||
- **Native answers render as JSON text** — the canonical value remains structured, but the model-facing result uses compact JSON rather than a richer content-block vocabulary.
|
||||
|
||||
@@ -161,6 +148,6 @@ These limits define when the tool is a poor fit. They are current package constr
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
The rollout keeps both definitions during adoption, then makes the timed tool the default, then removes the legacy definition and `mode: legacy` after existing profiles have migrated. Removing the old executable tool must not change historical Session replay: the [`userQuestions` projection](../user-questions/src/projection.ts) must keep distinguishing calls by the schema recorded in each `request/header`, as its [mixed-schema tests](../user-questions/tests/projection.spec.ts) do today. A missing `timeout` argument cannot identify an old call because timed calls may omit it too. `timeout: -1` provides an indefinite wait in timed mode but does not reproduce the old schema or card behavior. If the projection's state or fold changes during removal, bump its `stateVersion` so stored caches refold from the logs.
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`ask_user_question` 向用户请求确认、选择或缺失的信息。默认保持原有的阻塞式 legacy 行为不变:模型会等待用户回答。异步计时模式是显式的 Cordis 选择,设置 `mode: timed` 后启用;它等待前台窗口,随后返回待处理结果,让模型继续独立工作而问题仍可回答。在 timed 模式中,`timeout: -1` 让本次调用无限期阻塞。归属于另一个 agent 的存活子级不能调用此工具。本包不渲染界面;由调用方提供用户交互表面。
|
||||
`ask_user_question` 让模型暂停工作,向用户请求确认、选择或缺失的信息。它接受一个或多个问题,并以紧凑 JSON 返回回答。调用会等待回答被接受或当前轮次被取消;如果没有回答处理器接受请求,模型会收到错误。归属于运行时其他 agent 的子级不能调用此工具,必须在最终结果中报告尚未解决的问题。本包不渲染界面或收集输入,因此调用方必须提供兼容的用户交互表面。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -27,21 +27,9 @@ kind: "package-reference"
|
||||
|
||||
凡模型应当能够暂停等待人类决定的场景,都可组合此插件:它提供 `ask_user_question` 工具,并且需要带有接受作用域请求的 answerer 的 `ctx.userQuestions` seam。没有 answerer 接受时,工具调用会以错误失败,而不是降级。
|
||||
|
||||
随附 preset 不配置此插件,因此公开原有的阻塞式 legacy 工具。要启用异步行为,请在当前 preset 的 `config.plugins` 列表中,将 `tool-ask-user` 条目显式设为 `mode: timed`:
|
||||
|
||||
```yaml
|
||||
- id: tool-ask-user
|
||||
name: '@deepseek-ai/dsh-tool-ask-user'
|
||||
config:
|
||||
mode: timed
|
||||
timeout: 120
|
||||
```
|
||||
|
||||
以上片段是插件条目,而不是顶层 `--patch` 条目。随附 Web profile 将它嵌套在 `preset-standard` 中,因此 Web profile 补丁必须更新该 preset 的插件列表,或在 Agent Preset 编辑器中修改同一设置。使用 `mode: legacy`(或省略 `config`)即可保持阻塞式 schema。在 timed 模式下,条目的 `timeout` 是每次工具调用的默认值。设为 `-1` 会让每次调用无限期等待,除非模型传入正数 `timeout` 参数。参数中的 `timeout: -1` 只影响该次工具调用;一次调用可以包含多个问题,并由整批答案结算。
|
||||
|
||||
### 何时调用该工具
|
||||
|
||||
当模型需要确认、选择结果或缺失的信息才能继续时,调用 `ask_user_question`。发送一个或多个问题,每个问题的 `id` 在本次调用内必须唯一,回答中会原样包含;不同调用可以复用同一个 `id`,因为调用 ID 会区分它们。推荐选项放在首位,并在标签末尾追加 `(Recommended)`。可选的单次调用 `timeout` 以秒为单位;如果没有回答就无法安全继续,则用 `-1` 让新工具保持阻塞。超时绝不表示批准。Web 卡片还允许用户显式选择“慢慢回答”或开始编辑,这会让该 Client 的 pending 等待保持无限期,直到提交回答或取消调用。选择完整旧工具是 Cordis 组合决策,不是模型参数。
|
||||
当模型需要确认、选择结果或缺失的信息才能继续时,调用 `ask_user_question`。发送一个或多个问题,每个问题携带稳定的 `id`(回答中会原样包含);推荐选项放在首位,并在标签末尾追加 `(Recommended)`。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -61,7 +49,7 @@ kind: "package-reference"
|
||||
|
||||
### 模型得到什么
|
||||
|
||||
在超时前收到回答时,工具为每个问题返回一个回答对象:`selected` 保存选中的选项标签,`custom` 携带自由填写的回答——对多选题补充 `selected`,对单选题覆盖它。用户明确跳过的问题是一个已完成的回答项,其 `selected` 为空且没有 `custom`。相比之下,`{ "pending": true, "callId": "…" }` 表示初始等待到期前没有收到任何回答批次;问题仍可回答,模型只继续独立工作,之后的回答会作为普通用户消息送达,其 `kind`、`tool` 和 `callId` 会标识先前 pending 的工具调用。Web 对话会把该载荷展示为原问题及对应回答;其他消费者保留紧凑 JSON 文本。
|
||||
工具为每个问题返回一个回答对象:`selected` 保存选中的选项标签,`custom` 携带自由填写的回答——对多选题补充 `selected`,对单选题覆盖它。Native 渲染器保留紧凑的 JSON 文本形式。
|
||||
|
||||
```json
|
||||
{ "answers": [{ "id": "cleanup", "selected": ["Yes, delete them (Recommended)"] }] }
|
||||
@@ -69,7 +57,7 @@ kind: "package-reference"
|
||||
|
||||
### 调用何时失败
|
||||
|
||||
默认 legacy 模式下,工具调用始终等待用户回答。设置 `mode: timed` 后,异步工具调用会阻塞到用户作答、超时到期,或当前轮次被取消;`timeout: -1` 会让该次 timed 调用无限期阻塞。没有 answerer 接受、调用被中止、或调用方不是确切的存活运行时根,都会以模型在工具结果中看到的错误结算——最值得注意的是,归属于另一个 agent 的存活子级会被拒绝(`DELEGATED_CALLER`),必须在最终结果中包含尚未解决的问题或决定。
|
||||
工具调用会阻塞到用户作答,并且只能通过当前轮次的信号取消。没有 answerer 接受、调用被中止、或调用方不是确切的存活运行时根,都会以模型在工具结果中看到的错误结算——最值得注意的是,归属于另一个 agent 的存活子级会被拒绝(`DELEGATED_CALLER`),必须在最终结果中包含尚未解决的问题或决定。
|
||||
|
||||
-----
|
||||
|
||||
@@ -86,12 +74,11 @@ kind: "package-reference"
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 工具注册:`ask_user_question` schema、执行路径、结果渲染 |
|
||||
| [`src/legacy.ts`](src/legacy.ts) | 冻结的超时功能加入前描述、参数、输出和阻塞执行 |
|
||||
| — | 不发布运行时不变式伴生入口;此模型侧适配器没有独立的生命周期流;执行关系由其调用的能力 seam 负责。 |
|
||||
|
||||
### 消费方角色
|
||||
|
||||
该插件以 `['tools', 'userQuestions']` 注入,在 `ctx.tools` 上只注册一个 `defineTool` 条目。默认模式逐字注册原有的 legacy 工具——`src/legacy.ts` 中的一份冻结副本,与 timed 工具不共享任何 schema 或映射代码——每次调用都走阻塞式 `ask()`。Cordis 配置设为 `mode: timed` 时改为注册异步 schema:正数 timeout 走 `askTimed()`,`-1` 走阻塞式 `ask()`。两个定义绝不会同时出现。两者都会转发确切的调用 agent 与当前轮次信号;身份检查、waterfall(瀑布式事件)分派和错误分类由 seam 负责。timed 模式的每个请求都在 `wait` 中写明该调用,包括无限期的 `-1` 形式,Client 因此可以把提问界面绑定到这次工具调用;legacy 请求则不携带调用标识。timed schema 的 `timeout` 参数也正是 `userQuestions` projection 从记录的请求头中读取、用来区分 timed 调用与 legacy 调用的标志。
|
||||
该插件以 `['tools', 'userQuestions']` 注入,在 `ctx.tools` 上注册一个 `defineTool` 条目。`execute` 把模型参数映射为 `AskUserQuestionRequest`,转发确切的调用 agent 与当前轮次的信号,并把接受的回答映射回规范的 `answers` 数组。身份检查、意图校验、waterfall(瀑布式事件)分派与错误分类由 seam 拥有;本包只做转换。
|
||||
|
||||
### 结果渲染
|
||||
|
||||
@@ -120,7 +107,7 @@ kind: "package-reference"
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
随附 preset 会公开原有的阻塞式 [`ask_user_question` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-ask-user)。自定义 Cordis 行设置 `mode: timed` 后,会切换到包含问题 id、提示语、标题、选项、多选标志、`timeout` 与待处理结果的异步 schema;模型只会看到被选中的定义。
|
||||
模型会看到生成的 [`ask_user_question` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-ask-user),其中包含问题 id、提示语、标题、选项与多选标志。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -134,7 +121,7 @@ kind: "package-reference"
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型提出的完整问题保留在 assistant 工具调用参数中。前台等待期间收到的回答会在下一步显示为精确采用 `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}` 形式的紧凑 JSON;不使用 `custom` 时会省略该字段,`selected` 可以包含零个、一个或多个标签。待处理这一步显示为一个紧凑的 `{"pending":true,"callId":"<pending-call-id>","message":"<instruction>"}` 对象,其 `message` 指示模型继续独立工作;指示是对象里的字段而不是对象旁的自然语言,因为记录下来的结果文本还会被问题 projection 和 Web 卡片当作一个 JSON 对象读回。返回待处理结果后,最终回答会作为形如 `{"kind":"answer_to_pending_question","tool":"ask_user_question","callId":"<pending-call-id>","questions":[...],"answers":[...]}` 的用户消息送达。判别字段和调用 id 会明确告诉模型这是对先前 pending 调用的回答,重复的问题则让回答保持自包含。调用等待期间的 UI 交互不属于模型上下文。
|
||||
模型提出的完整问题保留在 assistant 工具调用参数中。用户回答后,下一步会看到精确采用 `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}` 形式的紧凑 JSON;不使用 `custom` 时会省略该字段,`selected` 可以包含零个、一个或多个标签。调用等待期间的 UI 交互不属于模型上下文。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -151,7 +138,7 @@ kind: "package-reference"
|
||||
|
||||
这些限制说明该工具何时不合适。它们是当前包约束,不是 UI 积压事项。
|
||||
|
||||
- **旧版工具不会报告 pending**:`mode: legacy` 保留阻塞式的内存等待,只返回回答或错误。它的调用永远不会进入 `userQuestions` projection,因为请求头记录的是 legacy schema,所以被进程丢失打断的旧版调用不会显示已继续的问题,也不接受迟到回答,与 timed 问题出现之前完全一致。
|
||||
- **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`。
|
||||
- **运行时中归属于其他 agent 的 subagent 不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝归属于另一个 agent 的存活子级;该子级必须在最终结果中包含尚未解决的问题或决定。持久谱系不能决定这一边界,因此带有谱系的会话恢复为运行时根后可以正常提问。
|
||||
- **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。
|
||||
|
||||
@@ -161,6 +148,6 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>维护者的工作上下文——点击展开</summary>
|
||||
|
||||
推广顺序是先在采用期间保留两种工具定义,再把 timed 工具设为默认,最后在现有 profile 完成迁移后移除 legacy 定义和 `mode: legacy`。移除旧工具的可执行代码不得改变历史 Session 的重放行为:[`userQuestions` projection](../user-questions/src/projection.ts) 必须继续根据每个 `request/header` 记录的 schema 区分调用;现有的[混合 schema 测试](../user-questions/tests/projection.spec.ts)已覆盖这一点。缺少 `timeout` 参数不能用来识别旧调用,因为 timed 调用也可以省略它。timed 模式下的 `timeout: -1` 提供无限期等待,但不会重现旧 schema 或卡片行为。如果移除过程中改变 projection 状态或折叠语义,应提高 `stateVersion`,让持久化缓存从日志重新折叠。
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -30,8 +30,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-tools": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:*",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
@@ -39,10 +38,6 @@
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:*",
|
||||
"@deepseek-ai/dsh-tools": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:*",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
}
|
||||
}
|
||||
@@ -1,128 +1,21 @@
|
||||
/**
|
||||
* Model-facing Consumer of the `ctx.userQuestions` capability seam.
|
||||
* The legacy blocking tool remains the default. The timed variant is an
|
||||
* explicit opt-in that can continue independent work after a foreground wait.
|
||||
* The tool pauses until a UI provider returns a human answer, then feeds that
|
||||
* answer back into the agent loop as an ordinary tool result.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-ask-user
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { TIMED_WAIT_PARAMETER } from '@deepseek-ai/dsh-user-questions'
|
||||
import type {
|
||||
AskUserQuestionAnswer, AskUserQuestionRequestEvent,
|
||||
} from '@deepseek-ai/dsh-user-questions/types'
|
||||
import { registerLegacyAskUser } from './legacy.ts'
|
||||
|
||||
/** Default wait in seconds; -1 retains the blocking tool behavior. */
|
||||
export interface Config {
|
||||
/** Tool definition selected by this Cordis row. Defaults to the blocking legacy tool. */
|
||||
mode?: 'legacy' | 'timed'
|
||||
/** Foreground wait before automatic continuation. Defaults to 120 seconds. */
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
mode: z.union(['legacy', 'timed']).default('legacy'),
|
||||
timeout: z.union([-1, z.number().step(1).min(1).max(2_147_483)]).default(120),
|
||||
})
|
||||
|
||||
function validateTimeout(timeout: number): number {
|
||||
if (timeout !== -1 && (!Number.isInteger(timeout) || timeout < 1 || timeout > 2_147_483)) {
|
||||
throw new Error('timeout must be -1 or a positive integer up to 2147483 seconds')
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
function validateQuestionIds(questions: readonly { readonly id: string }[]): void {
|
||||
const ids = new Set<string>()
|
||||
for (const question of questions) {
|
||||
if (ids.has(question.id)) throw new Error(`question id ${JSON.stringify(question.id)} must be unique within this call`)
|
||||
ids.add(question.id)
|
||||
}
|
||||
}
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import '@deepseek-ai/dsh-user-questions'
|
||||
|
||||
export const name = 'tool-ask-user'
|
||||
export const inject = ['tools', 'userQuestions']
|
||||
|
||||
const description = 'Ask brief, direct, self-contained questions about missing information, preferences, or decisions. '
|
||||
+ 'Use user-facing terms; assume no knowledge of background work or internal names. '
|
||||
+ 'Use distinct stable question IDs. A submitted skipped question is an answer item with empty selected and no custom; '
|
||||
+ 'pending instead means no answer batch arrived before the timeout and the user can still answer.'
|
||||
const description = 'Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding.'
|
||||
|
||||
/**
|
||||
* Instruction the pending result carries in its `message` field. It is a field
|
||||
* of the result value rather than prose beside it because the recorded result
|
||||
* text is read back as one JSON object: the `userQuestions` projection decides
|
||||
* from it that the call stays answerable, and the Client question row decides
|
||||
* from it that the recorded result is the timeout, not an answer batch.
|
||||
*/
|
||||
const pendingNotice = 'No answer batch arrived before the timeout. This is pending, not a skipped answer. '
|
||||
+ 'Continue useful independent work. The user can still answer; their reply will be a user message '
|
||||
+ 'identified as answer_to_pending_question with this callId and the original questions. '
|
||||
+ 'Do not treat this as permission.'
|
||||
|
||||
interface ToolQuestion {
|
||||
readonly id: string
|
||||
readonly question: string
|
||||
readonly header?: string
|
||||
readonly options?: { readonly label: string; readonly description?: string }[]
|
||||
readonly multi_select?: boolean
|
||||
}
|
||||
|
||||
interface ToolAnswerResult {
|
||||
readonly answers: {
|
||||
readonly id: string
|
||||
readonly selected: string[]
|
||||
readonly custom?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the model schema into the service request without changing ownership.
|
||||
* @param questions - Model-supplied questions in tool-schema form.
|
||||
* @param exec - Execution context that owns the agent and cancellation signal.
|
||||
* @returns The corresponding user-question service request.
|
||||
*/
|
||||
function questionRequest(
|
||||
questions: readonly ToolQuestion[],
|
||||
exec: Pick<ToolExecution, 'agent' | 'signal'>,
|
||||
): AskUserQuestionRequestEvent {
|
||||
return {
|
||||
questions: questions.map(question => ({
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
...question.header !== undefined ? { header: question.header } : {},
|
||||
...question.options !== undefined ? { options: question.options.map(option => ({ ...option })) } : {},
|
||||
...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {},
|
||||
})),
|
||||
...exec.agent !== undefined ? { agent: exec.agent } : {},
|
||||
signal: exec.signal,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy service-owned answer arrays into the model-facing result.
|
||||
* @param result - Answer returned by the user-question service.
|
||||
* @returns A detached model-facing answer payload.
|
||||
*/
|
||||
function answerResult(result: AskUserQuestionAnswer): ToolAnswerResult {
|
||||
return {
|
||||
answers: result.answers.map(answer => ({
|
||||
id: answer.id,
|
||||
selected: [...answer.selected],
|
||||
...answer.custom !== undefined ? { custom: answer.custom } : {},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
if (config.mode !== 'timed') {
|
||||
registerLegacyAskUser(ctx)
|
||||
return
|
||||
}
|
||||
const defaultTimeout = validateTimeout(config.timeout ?? 120)
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'ask_user_question',
|
||||
description,
|
||||
@@ -130,7 +23,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
questions: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
description: 'Questions to ask the user.',
|
||||
description: 'Questions to ask the user before continuing.',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
@@ -160,68 +53,48 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
},
|
||||
},
|
||||
},
|
||||
// The `userQuestions` projection reads this parameter's presence out of
|
||||
// the logged request header to tell a timed call from a legacy one.
|
||||
[TIMED_WAIT_PARAMETER]: {
|
||||
type: 'integer',
|
||||
description: `Wait seconds (default ${defaultTimeout}). Normally omit; continue independent work after timeout. Timeout is not approval; late replies include the questions. Use -1 only when an answer is required before continuing or the user explicitly requests an indefinite wait.`,
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object', additionalProperties: false,
|
||||
properties: {
|
||||
pending: {
|
||||
type: 'boolean', enum: [true], required: true,
|
||||
description: 'True when the foreground wait expired before the user submitted an answer batch. The questions remain answerable; this is not a skipped answer.',
|
||||
},
|
||||
callId: { type: 'string', required: true, description: 'Tool call whose unanswered questions remain pending.' },
|
||||
message: {
|
||||
type: 'string', required: true,
|
||||
description: 'How to continue while the questions stay answerable.',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
answers: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
selected: { type: 'array', required: true, items: { type: 'string' } },
|
||||
custom: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object', additionalProperties: false,
|
||||
properties: {
|
||||
answers: {
|
||||
type: 'array', required: true,
|
||||
description: 'Submitted answer batch with one item per question. A skipped question has empty selected and no custom; unlike pending, the user has completed the batch.',
|
||||
items: {
|
||||
type: 'object', additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true, description: 'Stable question id echoed from the request.' },
|
||||
selected: {
|
||||
type: 'array', required: true, items: { type: 'string' },
|
||||
description: 'Selected option labels. Empty with no custom means the user explicitly skipped this question.',
|
||||
},
|
||||
custom: { type: 'string', description: 'Optional free-form answer; omitted for a skipped question.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const timeout = validateTimeout(args.timeout ?? defaultTimeout)
|
||||
validateQuestionIds(args.questions)
|
||||
const request = questionRequest(args.questions, exec)
|
||||
if (timeout !== -1) {
|
||||
if (exec.agent === undefined) throw new Error('timed questions require a live agent')
|
||||
const result = await ctx.userQuestions.askTimed({ ...request, agent: exec.agent }, exec.callId, timeout * 1000)
|
||||
return 'pending' in result ? { ...result, message: pendingNotice } : answerResult(result)
|
||||
const result = await ctx.userQuestions.ask({
|
||||
questions: args.questions.map(question => ({
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
...question.header !== undefined ? { header: question.header } : {},
|
||||
...question.options !== undefined ? { options: question.options } : {},
|
||||
...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {},
|
||||
})),
|
||||
...exec.agent !== undefined ? { agent: exec.agent } : {},
|
||||
signal: exec.signal,
|
||||
})
|
||||
return {
|
||||
answers: result.answers.map(answer => ({
|
||||
id: answer.id,
|
||||
selected: [...answer.selected],
|
||||
...answer.custom !== undefined ? { custom: answer.custom } : {},
|
||||
})),
|
||||
}
|
||||
// `wait` without `timed`: this tool's indefinite form still keys the
|
||||
// Client card by call id, so the UI can reopen a closed panel from the
|
||||
// tool call. The legacy tool keeps its unkeyed request.
|
||||
const result = await ctx.userQuestions.ask({ ...request, wait: { callId: exec.callId } })
|
||||
return answerResult(result)
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Frozen legacy blocking `ask_user_question` registration: the tool exactly as
|
||||
* it shipped before timed questions existed, selected by the default Cordis
|
||||
* row. Its schema, request mapping, and result mapping are kept verbatim and
|
||||
* share nothing with the timed tool, so a change to the timed tool cannot
|
||||
* reach a deployment that never opted in.
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const description = 'Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding.'
|
||||
|
||||
/**
|
||||
* Register the pre-timeout blocking tool definition selected by Cordis config.
|
||||
* @param ctx - Agent-scoped context receiving the legacy model tool.
|
||||
*/
|
||||
export function registerLegacyAskUser(ctx: Context): void {
|
||||
/* jscpd:ignore-start -- The legacy tool is a frozen copy; the timed tool in index.ts evolves independently of it. */
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'ask_user_question',
|
||||
description,
|
||||
parameters: {
|
||||
questions: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
description: 'Questions to ask the user before continuing.',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' },
|
||||
question: { type: 'string', required: true, description: 'The specific question to ask the user.' },
|
||||
header: {
|
||||
type: 'string',
|
||||
description: 'Optional short heading for the question, such as "Confirm" or "Choose Mode".',
|
||||
},
|
||||
options: {
|
||||
type: 'array',
|
||||
description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
|
||||
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
multi_select: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the user may select more than one option. Defaults to false.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
answers: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { type: 'string', required: true },
|
||||
selected: { type: 'array', required: true, items: { type: 'string' } },
|
||||
custom: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const result = await ctx.userQuestions.ask({
|
||||
questions: args.questions.map(question => ({
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
...question.header !== undefined ? { header: question.header } : {},
|
||||
...question.options !== undefined ? { options: question.options } : {},
|
||||
...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {},
|
||||
})),
|
||||
...exec.agent !== undefined ? { agent: exec.agent } : {},
|
||||
signal: exec.signal,
|
||||
})
|
||||
return {
|
||||
answers: result.answers.map(answer => ({
|
||||
id: answer.id,
|
||||
selected: [...answer.selected],
|
||||
...answer.custom !== undefined ? { custom: answer.custom } : {},
|
||||
})),
|
||||
}
|
||||
},
|
||||
}))
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
@@ -1,48 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createToolResultMessage, ToolCallId, type ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId, SessionSeq, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import UserQuestionService, {
|
||||
isTimedAskUserQuestionSchema,
|
||||
type AskUserQuestionAnswer,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-questions'
|
||||
// The fold is the projection's own reader of a recorded result, not a service method.
|
||||
import { foldUserQuestions } from '@deepseek-ai/dsh-user-questions/src/projection.ts'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/** The `request/header` event that records the exact tool schemas the model saw. */
|
||||
function headerEvent(seq: number, tools: ToolSchema[]): SessionEvent {
|
||||
return {
|
||||
type: 'request/header', seq: SessionSeq(seq), time: seq,
|
||||
data: { header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, tools }, reason: 'initial' },
|
||||
}
|
||||
}
|
||||
|
||||
/** The `tool/call` event a recorded `ask_user_question` call appends. */
|
||||
function askedEvent(seq: number, callId: string, argumentsText: string): SessionEvent {
|
||||
return {
|
||||
type: 'tool/call', seq: SessionSeq(seq), time: seq,
|
||||
data: { turn: 1, step: 1, callId: ToolCallId(callId), name: 'ask_user_question', arguments: argumentsText },
|
||||
}
|
||||
}
|
||||
|
||||
/** The `tool/result` event that records one rendered result text. */
|
||||
function resultedEvent(seq: number, callId: string, text: string): SessionEvent {
|
||||
return {
|
||||
type: 'tool/result', seq: SessionSeq(seq), time: seq,
|
||||
data: {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({ callId: ToolCallId(callId), content: [{ type: 'text', text }], isError: false }),
|
||||
},
|
||||
} as SessionEvent
|
||||
}
|
||||
|
||||
interface QuestionAnswerer {
|
||||
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
|
||||
}
|
||||
@@ -67,106 +36,25 @@ interface OptionSchemaShape {
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(config: toolAskUser.Config = { mode: 'timed', timeout: -1 }) {
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(toolAskUser, config)
|
||||
await ctx.plugin(toolAskUser)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function stubAgent(id: string, delegationDepth = 0): Agent {
|
||||
const agentId = SessionId(id)
|
||||
const agent: Agent = {
|
||||
const agentId = id as Agent['id']
|
||||
return {
|
||||
id: agentId,
|
||||
options: {},
|
||||
session: Session.create(agentId, [], {
|
||||
version: SESSION_FORMAT_VERSION, id: agentId, createdAt: 0, isSeeded: false, delegationDepth,
|
||||
}),
|
||||
inbox: {
|
||||
nextTurn: [], nextStep: [], clear() {}, append() {}, prepend() {},
|
||||
replace: () => false, remove: () => false, splice: () => [],
|
||||
},
|
||||
status: 'idle',
|
||||
ctx: new Context(),
|
||||
send() {}, followup() {}, steer() {}, inject() {}, cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
return agent
|
||||
session: { id: agentId, header: { delegationDepth } },
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
describe('ask_user_question tool', () => {
|
||||
it('keeps the blocking legacy tool when apply receives no config', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
|
||||
toolAskUser.apply(ctx)
|
||||
|
||||
const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question')
|
||||
expect(schema?.parameters).not.toHaveProperty('properties.timeout')
|
||||
expect(schema?.description).toContain('Ask the user a concise question')
|
||||
})
|
||||
|
||||
it('registers the timed tool only when the Cordis row opts in', async () => {
|
||||
const ctx = await setup({ mode: 'timed' })
|
||||
|
||||
const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question')
|
||||
|
||||
expect(schema?.parameters).toHaveProperty('properties.timeout')
|
||||
expect(JSON.stringify(schema)).toContain('pending')
|
||||
expect(JSON.stringify(schema)).toContain('Wait seconds (default 120)')
|
||||
})
|
||||
|
||||
it('falls back to the 120 second wait when apply receives a row without a timeout', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
|
||||
toolAskUser.apply(ctx, { mode: 'timed' })
|
||||
|
||||
expect(JSON.stringify(ctx.tools.schemas().find(tool => tool.name === 'ask_user_question'))).toContain('Wait seconds (default 120)')
|
||||
})
|
||||
|
||||
it('returns the answer batch when the Client answers inside the configured wait', async () => {
|
||||
const ctx = await setup({ mode: 'timed', timeout: 30 })
|
||||
registerQuestionAnswerer(ctx, {
|
||||
async ask() {
|
||||
return { answers: [{ id: 'continue', selected: ['yes'] }] }
|
||||
},
|
||||
})
|
||||
const agent = { ...stubAgent('timed-in-time'), steer: vi.fn() } as Agent
|
||||
ctx.agents.enter(agent, undefined)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: ToolCallId('ask-in-time'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
|
||||
agent,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: false,
|
||||
content: [{ type: 'text', text: '{"answers":[{"id":"continue","selected":["yes"]}]}' }],
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('describes the configured default timeout to the model', async () => {
|
||||
const ctx = await setup({ mode: 'timed', timeout: 45 })
|
||||
|
||||
const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question')
|
||||
|
||||
expect(JSON.stringify(schema)).toContain('Wait seconds (default 45)')
|
||||
expect(JSON.stringify(schema)).not.toContain('Wait seconds (default 120)')
|
||||
})
|
||||
|
||||
it('registers a model-facing tool schema', async () => {
|
||||
const ctx = await setup()
|
||||
const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question')
|
||||
@@ -177,7 +65,6 @@ describe('ask_user_question tool', () => {
|
||||
type: 'object',
|
||||
properties: {
|
||||
questions: { type: 'array' },
|
||||
timeout: { type: 'integer' },
|
||||
},
|
||||
required: ['questions'],
|
||||
},
|
||||
@@ -197,81 +84,6 @@ describe('ask_user_question tool', () => {
|
||||
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('value')
|
||||
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('recommended')
|
||||
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('preview')
|
||||
expect(schema?.description).toContain('A submitted skipped question is an answer item with empty selected and no custom')
|
||||
})
|
||||
|
||||
it('lets Cordis select the exact legacy blocking tool instead of the timed tool', async () => {
|
||||
const ctx = await setup({ mode: 'legacy' })
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
const firstAnswerer = registerQuestionAnswerer(ctx, {
|
||||
async ask(request) {
|
||||
seen.push(request)
|
||||
return { answers: [{ id: 'continue', selected: ['yes'] }] }
|
||||
},
|
||||
})
|
||||
const schemas = ctx.tools.schemas().filter(tool => tool.name === 'ask_user_question')
|
||||
|
||||
expect(schemas).toHaveLength(1)
|
||||
expect(schemas[0]).toMatchObject({
|
||||
description: 'Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding.',
|
||||
parameters: { properties: { questions: { description: 'Questions to ask the user before continuing.' } } },
|
||||
})
|
||||
expect(schemas[0]?.parameters.properties).not.toHaveProperty('timeout')
|
||||
// The projection tells the two tools apart by this schema alone, so a
|
||||
// legacy Session never folds into a question card or a transcript pill.
|
||||
expect(isTimedAskUserQuestionSchema(schemas[0]!)).toBe(false)
|
||||
expect(foldUserQuestions([
|
||||
headerEvent(0, ctx.tools.schemas()),
|
||||
askedEvent(1, 'ask-legacy', '{"questions":[{"id":"continue","question":"Continue?"}]}'),
|
||||
])).toEqual({ active: [], settled: [] })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: ToolCallId('ask-legacy'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
isError: false,
|
||||
value: { answers: [{ id: 'continue', selected: ['yes'] }] },
|
||||
content: [{ type: 'text', text: '{"answers":[{"id":"continue","selected":["yes"]}]}' }],
|
||||
})
|
||||
// The legacy request stays unkeyed: its Client card has no tool call row to
|
||||
// reopen from, so closing that card still cancels the whole request.
|
||||
expect(seen[0]?.wait).toBeUndefined()
|
||||
expect(seen[0]?.agent).toBeUndefined()
|
||||
expect(seen[0]?.questions).toEqual([{ id: 'continue', question: 'Continue?' }])
|
||||
|
||||
// Every optional field crosses the seam exactly as the original tool
|
||||
// carried it, and a custom answer comes back verbatim.
|
||||
firstAnswerer()
|
||||
registerQuestionAnswerer(ctx, {
|
||||
async ask(request) {
|
||||
seen.push(request)
|
||||
return { answers: [{ id: 'pkg', selected: [], custom: 'bun' }] }
|
||||
},
|
||||
})
|
||||
const agent = stubAgent('legacy-agent')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
const detailed = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: ToolCallId('ask-legacy-detailed'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{
|
||||
id: 'pkg', question: 'Which package manager?', header: 'Setup', multi_select: false,
|
||||
options: [{ label: 'pnpm (Recommended)', description: 'Workspace default.' }, { label: 'npm' }],
|
||||
}] },
|
||||
agent,
|
||||
})
|
||||
expect(seen[1]?.agent).toBe(agent)
|
||||
expect(seen[1]?.questions).toEqual([{
|
||||
id: 'pkg', question: 'Which package manager?', header: 'Setup', multiSelect: false,
|
||||
options: [{ label: 'pnpm (Recommended)', description: 'Workspace default.' }, { label: 'npm' }],
|
||||
}])
|
||||
expect(detailed).toMatchObject({
|
||||
isError: false,
|
||||
content: [{ type: 'text', text: '{"answers":[{"id":"pkg","selected":[],"custom":"bun"}]}' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('asks the registered user-questions provider and projects structured answers to text', async () => {
|
||||
@@ -307,11 +119,7 @@ describe('ask_user_question tool', () => {
|
||||
question: 'Which package manager should I use?',
|
||||
options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
|
||||
}],
|
||||
// Keyed by the call with no deadline: the Client card is reopenable from
|
||||
// the tool call row, and the wait itself stays indefinite.
|
||||
wait: { callId: 'ask-1' },
|
||||
}])
|
||||
expect(seen[0]?.wait?.timed).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes recommended option labels through without adding schema fields', async () => {
|
||||
@@ -355,7 +163,6 @@ describe('ask_user_question tool', () => {
|
||||
{ id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
|
||||
{ id: 'labels-only', selected: ['tests'] },
|
||||
{ id: 'notes', selected: [], custom: 'ship today' },
|
||||
{ id: 'optional', selected: [] },
|
||||
],
|
||||
}
|
||||
},
|
||||
@@ -380,7 +187,6 @@ describe('ask_user_question tool', () => {
|
||||
multi_select: true,
|
||||
},
|
||||
{ id: 'notes', question: 'Any note?' },
|
||||
{ id: 'optional', question: 'Anything else?' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -392,12 +198,11 @@ describe('ask_user_question tool', () => {
|
||||
{ id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
|
||||
{ id: 'labels-only', selected: ['tests'] },
|
||||
{ id: 'notes', selected: [], custom: 'ship today' },
|
||||
{ id: 'optional', selected: [] },
|
||||
],
|
||||
})
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text',
|
||||
text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"},{"id":"optional","selected":[]}]}',
|
||||
text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -443,162 +248,7 @@ describe('ask_user_question tool', () => {
|
||||
})
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"continue","selected":["ok"]}]}' }])
|
||||
expect(seen[0]).toMatchObject({
|
||||
questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }],
|
||||
agent: { id: agent.id },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a pending question when the Client settles the configured deadline', async () => {
|
||||
const ctx = await setup({ mode: 'timed', timeout: 1 })
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
registerQuestionAnswerer(ctx, {
|
||||
ask: (request) => {
|
||||
seen.push(request)
|
||||
const timedOut = new Error('client countdown ended') as Error & { code: string }
|
||||
timedOut.name = 'UserQuestionError'
|
||||
timedOut.code = 'ASK_TIMED_OUT'
|
||||
return Promise.reject(timedOut)
|
||||
},
|
||||
})
|
||||
const agent = { ...stubAgent('timed-root'), steer: vi.fn() } as Agent
|
||||
ctx.agents.enter(agent, undefined)
|
||||
const settled = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: ToolCallId('ask-timeout'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
|
||||
agent,
|
||||
})
|
||||
|
||||
expect(seen[0]?.wait?.callId).toBe('ask-timeout')
|
||||
expect(seen[0]?.wait?.timed).toBe(true)
|
||||
expect(settled).toMatchObject({
|
||||
isError: false,
|
||||
value: { pending: true, callId: 'ask-timeout' },
|
||||
})
|
||||
const content = settled.content[0]
|
||||
expect(content?.type).toBe('text')
|
||||
if (content?.type !== 'text') throw new Error('expected a text tool result')
|
||||
// One JSON object, instruction included: the `userQuestions` projection and
|
||||
// the Client question row both read this recorded text back as JSON, and a
|
||||
// prose prefix would make them read a timed-out call as a closed one.
|
||||
const payload: unknown = JSON.parse(content.text)
|
||||
expect(payload).toEqual({
|
||||
pending: true,
|
||||
callId: 'ask-timeout',
|
||||
message: expect.stringContaining('This is pending, not a skipped answer.') as string,
|
||||
})
|
||||
expect(content.text).toContain('No answer batch arrived before the timeout.')
|
||||
expect(content.text).toContain('Do not treat this as permission.')
|
||||
// The question the model timed out on stays answerable: the projection
|
||||
// reads the same recorded text, so this pins the two readings together.
|
||||
expect(isTimedAskUserQuestionSchema(ctx.tools.schemas().find(tool => tool.name === 'ask_user_question')!)).toBe(true)
|
||||
expect(foldUserQuestions([
|
||||
headerEvent(0, ctx.tools.schemas()),
|
||||
askedEvent(1, 'ask-timeout', '{"questions":[{"id":"continue","question":"Continue?"}]}'),
|
||||
resultedEvent(2, 'ask-timeout', content.text),
|
||||
]).active).toEqual([{
|
||||
callId: 'ask-timeout',
|
||||
questions: [{ id: 'continue', question: 'Continue?' }],
|
||||
state: 'continued',
|
||||
}])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('holds an unclaimed timed question until the deadline and then reports it pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ctx = await setup({ mode: 'timed', timeout: 1 })
|
||||
const agent = { ...stubAgent('timed-unclaimed'), steer: vi.fn() } as Agent
|
||||
ctx.agents.enter(agent, undefined)
|
||||
const result = ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: ToolCallId('ask-unclaimed'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
|
||||
agent,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
await expect(result).resolves.toMatchObject({ isError: false, value: { pending: true, callId: 'ask-unclaimed' } })
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('requires a live agent for the timed path', async () => {
|
||||
const ctx = await setup({ mode: 'timed' })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: ToolCallId('ask-no-agent'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Error: timed questions require a live agent' }],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([0, 1.5, 2_147_484])('rejects invalid timeout %s', async (timeout) => {
|
||||
const ctx = await setup({ mode: 'timed', timeout: -1 })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: ToolCallId(`ask-invalid-${timeout}`),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', question: 'Continue?' }], timeout },
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ type: 'text' })
|
||||
expect(result.content[0]?.type === 'text' ? result.content[0].text : '').toMatch(/timeout.*integer/i)
|
||||
})
|
||||
|
||||
it('rejects duplicate question ids before dispatching a timed call', async () => {
|
||||
const ctx = await setup({ mode: 'timed', timeout: -1 })
|
||||
const answerer = vi.fn(async (): Promise<AskUserQuestionAnswer> => ({ answers: [] }))
|
||||
registerQuestionAnswerer(ctx, { ask: answerer })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: ToolCallId('ask-duplicate-ids'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [
|
||||
{ id: 'scope', question: 'Choose a scope.' },
|
||||
{ id: 'scope', question: 'Confirm the scope.' },
|
||||
], timeout: -1 },
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Error: question id "scope" must be unique within this call' }],
|
||||
})
|
||||
expect(answerer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows separate timed calls to reuse a question id', async () => {
|
||||
const ctx = await setup({ mode: 'timed', timeout: -1 })
|
||||
const answerer = vi.fn(async (): Promise<AskUserQuestionAnswer> => ({
|
||||
answers: [{ id: 'scope', selected: ['Tools'] }],
|
||||
}))
|
||||
registerQuestionAnswerer(ctx, { ask: answerer })
|
||||
|
||||
for (const callId of ['first-scope', 'second-scope']) {
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: ToolCallId(callId),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'scope', question: 'Choose a scope.' }], timeout: -1 },
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
}
|
||||
expect(answerer).toHaveBeenCalledTimes(2)
|
||||
expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent })
|
||||
})
|
||||
|
||||
it('returns structured user-questions errors through tool execution', async () => {
|
||||
|
||||
@@ -9,17 +9,26 @@
|
||||
en: 79d2910e509a2512
|
||||
zh: 7af1982658f00d64
|
||||
/deepseek-ai-dsh-user-questions/summary:
|
||||
en: f8102effb72e5c07
|
||||
zh: a12d57c7e7b13ebe
|
||||
en: d27ce8b495269248
|
||||
zh: 691a7cc7f3d892d0
|
||||
/deepseek-ai-dsh-user-questions/table-of-contents:
|
||||
en: 382a21c0ea17b2e4
|
||||
zh: 1a4335e571de92dd
|
||||
/deepseek-ai-dsh-user-questions/ask-a-question:
|
||||
en: 0c45d3161b0df13a
|
||||
zh: efff84a7917a799e
|
||||
en: ad3919bb107cc38d
|
||||
zh: 557af29a8318b65c
|
||||
/deepseek-ai-dsh-user-questions/service-userquestionservice-ctx-key-userquestions:
|
||||
en: 1ba3467a2588da42
|
||||
zh: 74f6e516f71116d9
|
||||
/deepseek-ai-dsh-user-questions/service-userquestionservice-ctx-key-userquestions/public-api:
|
||||
en: f7c183c8728b05cd
|
||||
zh: f0104632d8eb2938
|
||||
/deepseek-ai-dsh-user-questions/service-userquestionservice-ctx-key-userquestions/key-types:
|
||||
en: 10637ca40891d520
|
||||
zh: 015662eb651d19cb
|
||||
/deepseek-ai-dsh-user-questions/service-userquestionservice-ctx-key-userquestions/presentation-intent:
|
||||
en: d3809cfa854f1c02
|
||||
zh: f9bcc88f1db8fd83
|
||||
/deepseek-ai-dsh-user-questions/role:
|
||||
en: 6a5b7d927c5c3269
|
||||
zh: e30a9e159d3fb933
|
||||
en: 1d4b9680e35350a7
|
||||
zh: fcba25211099b880
|
||||
/deepseek-ai-dsh-user-questions/model-experience:
|
||||
en: 95167d0c957a0ab5
|
||||
zh: 582c04395648786c
|
||||
@@ -27,8 +36,8 @@
|
||||
en: 40294c1c31b74824
|
||||
zh: cd40bdfe1a1c37dc
|
||||
/deepseek-ai-dsh-user-questions/known-limitations-and-deferred-work:
|
||||
en: a2334c0fcb02d2d8
|
||||
zh: 4592c204397387ac
|
||||
en: 9c2d868427856d56
|
||||
zh: f0d19979fd90d826
|
||||
/deepseek-ai-dsh-user-questions/known-limitations-and-deferred-work/dev-note:
|
||||
en: fc85497a8ec0ab89
|
||||
zh: bf4dd46c37ca669b
|
||||
en: f5f2c82d2e085ccf
|
||||
zh: d04ca73d1db0ef0f
|
||||
@@ -9,11 +9,11 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Use `ctx.userQuestions` when a tool or permission flow needs a structured answer from the user. Ordinary questions can wait briefly and then let independent work continue; unanswered timed questions remain attached to the Session until the user answers them.
|
||||
User-interaction Service Definition. It owns `ctx.userQuestions`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision. Use it when a consumer must suspend an operation until the user answers.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Ask a question](#service-userquestionservice-ctx-key-userquestions)
|
||||
- [Service: `UserQuestionService` (ctx key: `userQuestions`)](#service-userquestionservice-ctx-key-userquestions)
|
||||
- [Role](#role)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
@@ -22,22 +22,32 @@ Use `ctx.userQuestions` when a tool or permission flow needs a structured answer
|
||||
-----
|
||||
|
||||
<a id="service-userquestionservice-ctx-key-userquestions"></a>
|
||||
## Ask a question
|
||||
## Service: `UserQuestionService` (ctx key: `userQuestions`)
|
||||
|
||||
Call `ask()` when work cannot continue without the answer. Call `askTimed()` when the agent may continue independent work after a foreground wait. An answer UI claims that wait through `attachWait`, receives the Host-computed remaining duration, and counts down on its own clock. Without a claim, including after the last Client disconnects, the service releases the model at the original deadline. The `userQuestions` projection derives durable questions from the tool call, its result, and the eventual reply; Client claims are not persisted.
|
||||
### Public API
|
||||
|
||||
- `ctx.userQuestions.ask(request): Promise<AskUserQuestionAnswer>` Dispatch the answerer waterfall and wait for the first accepted answer.
|
||||
|
||||
### Key Types
|
||||
|
||||
- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label. When present, `agent` must be the registry's exact live runtime root.
|
||||
- `AskUserQuestionOption` — `{ label, description? }`.
|
||||
- `AskUserQuestionIntent` — `{ kind: 'plan-review', approve }`; the tagged presentation intent below.
|
||||
- `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`.
|
||||
- `UserQuestionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `ASK_ABORTED`, `CALLER_NOT_LIVE`, and `DELEGATED_CALLER`.
|
||||
|
||||
For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
|
||||
|
||||
A question may carry a presentation `intent`, which declares that it IS a known kind of decision so a UI that recognises the tag may present it as such; the one tag is `plan-review`, whose `detail` is the plan under review and whose `approve` names the affirmative option. An intent changes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail`. `dsh-plan-mode` sets it on the `exit_plan_mode` review question.
|
||||
When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. Durable lineage is not authority: a session with historical delegation depth may ask after it is resumed as a new runtime root, while a live child owned by another agent is rejected even if its durable depth is zero. The Web answerer receives only Agent-scoped requests; an agentless programmatic request remains available to unscoped local waterfall listeners and fails with `NO_PROVIDER` when none accepts it.
|
||||
|
||||
When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. A live child cannot open a human interaction. An agentless programmatic request remains available to unscoped local waterfall listeners and fails with `NO_PROVIDER` when none accepts it.
|
||||
### Presentation intent
|
||||
|
||||
While the tool call is open, the only answer path is that request; a browser that reconnects receives it again and can still complete it. Once the call has returned pending, or the process that owned it ended, the question is `continued`: the `answer` Remote method steers the reply into the agent as a `user-question-reply` message. No Remote method abandons a question — a Client that puts its panel away sends nothing, so the call stays answerable until an answer arrives. Answering a closed Session resumes its root agent first. Neither path fabricates a result for the finished tool call.
|
||||
`intent` declares that a question IS a known kind of decision, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent changes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read the same answer fields either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of.
|
||||
|
||||
<a id="role"></a>
|
||||
## Role
|
||||
|
||||
`UserQuestionService` owns each `TimedQuestionWait`, its cancellable Client claims, and its unattended timer. A claimed wait leaves countdown and focus/edit decisions to the Client. An unattended timeout aborts only the foreground request signal and returns pending, never aborting the Turn. The `userQuestions` projection records open, continued, and settled timed calls from existing Session events; legacy calls are excluded by the logged tool schema. The `answer` RPC accepts only continued questions and validates one answer per question before steering the reply. A late batch stays in the projection because the original tool result contains the timeout, not that answer. The retained `dismissed` source outcome is readable but has no producer.
|
||||
This is the Service Definition package. Consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this service; the Web client contributes an Agent-scoped answerer through Remote Events. The loop stays unchanged: a tool call awaits the waterfall result, and that result resumes the normal agent loop.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
@@ -54,7 +64,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
- **Agent-scoped Web answering** — Remote Events route the shipped Web answerer only when the request carries a live Agent scope; agentless callers need an unscoped local waterfall listener.
|
||||
- **The vocabulary is the question-form shape only** — selectable options plus optional custom text; richer interaction shapes (file pickers, diff-preview confirmations) have no seam vocabulary yet.
|
||||
- **Draft text is not part of Host question persistence** — the question survives restart across clients through the projection; unfinished input remains local to one browser profile.
|
||||
|
||||
|
||||
<a id="dev-note"></a>
|
||||
@@ -65,6 +74,8 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
None.
|
||||
|
||||
The optional plan-review `callId` identifies the logged tool invocation for document navigation. It does not change the answer or its validation.
|
||||
|
||||
</details>
|
||||
|
||||
**Runtime invariant:** the `userQuestions` projection is derived from the tool and inbox events the agent loop already logs; no separate question state is stored. A continued question can create a new user turn, but it cannot resume a finished tool call. No runtime invariant companion is published because the projection fold and the continued-only Remote methods enforce this boundary at their owners.
|
||||
**Runtime invariant:** No companion is published. The answerer waterfall is resolved per request and returns directly to its caller; the seam publishes no independent request/answer audit stream.
|
||||
@@ -9,11 +9,11 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
当工具或权限流程需要用户提供结构化答案时,使用 `ctx.userQuestions`。普通问题可以短暂等待,然后让独立工作继续;未回答的计时问题会一直附着在会话上,直到用户回答。
|
||||
用户交互 Service Definition。它定义 `ctx.userQuestions`,供面向模型的工具或权限插件在需要暂停工作并询问人类决定时使用。当消费方必须暂停操作并等待用户回答时,请使用它。
|
||||
|
||||
## 目录
|
||||
|
||||
- [提出问题](#service-userquestionservice-ctx-key-userquestions)
|
||||
- [服务:`UserQuestionService`(ctx 键:`userQuestions`)](#service-userquestionservice-ctx-key-userquestions)
|
||||
- [职责](#role)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与暂缓事项](#known-limitations-and-deferred-work)
|
||||
@@ -22,22 +22,32 @@ kind: "package-reference"
|
||||
-----
|
||||
|
||||
<a id="service-userquestionservice-ctx-key-userquestions"></a>
|
||||
## 提出问题
|
||||
## 服务:`UserQuestionService`(ctx 键:`userQuestions`)
|
||||
|
||||
当工作无法在没有答案的情况下继续时调用 `ask()`。当 agent 可以在一段前台等待后继续独立工作时调用 `askTimed()`。回答 UI 通过 `attachWait` 接手等待,取得 Host 计算的剩余时长,并用本地时钟倒计时。没有接手方时,包括最后一个 Client 断开之后,服务在原 deadline 放行模型。`userQuestions` projection 从工具调用、其结果和最终回复派生持久问题;Client 接手状态不落盘。
|
||||
### 公开 API
|
||||
|
||||
- `ctx.userQuestions.ask(request): Promise<AskUserQuestionAnswer>` 派发回答者 waterfall,并等待首个被接受的回答。
|
||||
|
||||
### 关键类型
|
||||
|
||||
- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。如提供 `agent`,它必须与注册表中的存活运行时根 agent(智能体)是同一对象。
|
||||
- `AskUserQuestionOption`:`{ label, description? }`。
|
||||
- `AskUserQuestionIntent`:`{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。
|
||||
- `AskUserQuestionAnswer`:`{ answers: [{ id, selected, custom? }] }`。
|
||||
- `UserQuestionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`ASK_ABORTED`、`CALLER_NOT_LIVE` 和 `DELEGATED_CALLER` 等代码。
|
||||
|
||||
对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
|
||||
|
||||
问题可以携带呈现意图 `intent`,声明它就是某种已知决策,识别该标记的 UI 可以按该决策呈现;目前唯一的标记是 `plan-review`,其 `detail` 是待审阅的计划,`approve` 指明表示同意的选项。意图只改变呈现:遵循它的 UI 回答的选项标签与通用 UI 相同,不认识该标记的 UI 则渲染通用选项列表。`ask()` 会以 `BAD_INTENT` 拒绝两种类型无法表达的断言:`approve` 未命名该问题自己的任何选项,以及没有 `detail` 的问题声明了意图。`dsh-plan-mode` 在 `exit_plan_mode` 的审阅问题上设置它。
|
||||
请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。持久谱系不构成权限依据:带有历史委托深度的会话恢复为新的运行时根后可以提问;归属于另一个 agent 的存活子级即使持久化记录的委托深度为零也会被拒绝。Web 回答者只接收带 Agent scope 的请求;不含 agent 的程序化请求仍会交给本地未限定 scope 的 waterfall listener,若无人接受则以 `NO_PROVIDER` 失败。
|
||||
|
||||
请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。存活子级不能发起人机交互。不含 agent 的程序化请求仍会交给本地未限定 scope 的 waterfall listener,若无人接受则以 `NO_PROVIDER` 失败。
|
||||
### 呈现意图
|
||||
|
||||
工具调用开放期间,唯一的回答路径就是这条请求;重新连接的浏览器会再次收到它,仍可完成。调用返回 pending 之后,或拥有它的进程结束之后,问题进入 `continued`:`answer` Remote 方法把回复作为 `user-question-reply` 消息 steer 给 agent。没有任何 Remote 方法会放弃问题——Client 收起面板时不发送任何内容,因此该调用在收到回答前一直可回答。回答已关闭的会话时先恢复其根 agent。两条路径都不会为已结束的工具调用伪造结果。
|
||||
`intent` 声明某个问题本身就是一种已知决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只改变呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的回答字段相同。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言无法通过类型表达,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。
|
||||
|
||||
<a id="role"></a>
|
||||
## 职责
|
||||
|
||||
`UserQuestionService` 拥有每个 `TimedQuestionWait`、可取消的 Client 接手记录和无人接手计时器。有人接手时,倒计时与聚焦/编辑决策归 Client 所有。无人接手时超时只中止前台请求的 signal 并返回 pending,不中止 Turn。`userQuestions` projection 从现有 Session 事件记录开放、已继续和已结算的计时调用;记录的工具 schema 排除 legacy 调用。`answer` RPC 只接受已继续的问题,在确认每个问题恰有一条回答后 steer 回复。迟到批次保留在投影中,因为原工具结果包含的是超时而不是该回答。保留的 `dismissed` 来源结果仍可读取,但没有生产者。
|
||||
这是 Service Definition 包。`@deepseek-ai/dsh-tool-ask-user` 等 Consumer 依赖此服务;Web Client 通过 Remote Events 贡献带 Agent scope 的回答者。循环保持不变:工具调用等待 waterfall 结果,该结果随后恢复正常的 agent loop(智能体循环)。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
@@ -54,7 +64,6 @@ kind: "package-reference"
|
||||
|
||||
- **带 Agent scope 的 Web 回答**:Remote Events 仅在请求带有存活 Agent scope 时路由随产品交付的 Web 回答者;agentless 调用方需要本地未限定 scope 的 waterfall listener。
|
||||
- **词汇仅包含问题表单形态**:可供选择的选项加可选的自定义文本;更丰富的交互形态(文件选择器、diff 预览确认)尚无 seam 词汇。
|
||||
- **草稿文本不属于 Host 问题持久化状态**:问题通过 projection 跨客户端重启保留;未完成的输入仍只保存在一个浏览器配置中。
|
||||
|
||||
|
||||
<a id="dev-note"></a>
|
||||
@@ -65,6 +74,8 @@ kind: "package-reference"
|
||||
|
||||
无。
|
||||
|
||||
计划审批中可选的 `callId` 标识已记录的工具调用,供文档导航使用,不改变回答及其校验。
|
||||
|
||||
</details>
|
||||
|
||||
**运行时不变式:** `userQuestions` projection 由 agent loop 本来就记录的工具与 inbox 事件推导,不存储单独的问题状态。已继续的问题可以创建新的用户轮次,但不能恢复已结束的工具调用。此包不发布运行时不变量 companion,因为 projection 折叠和只接受已继续问题的 Remote 方法已在各自归属处保证这一边界。
|
||||
**运行时不变式:** 不发布伴生入口。answerer waterfall 按请求解析并把结果直接返回调用方;该 seam 不发布独立的请求/回答审计流。
|
||||
@@ -23,47 +23,24 @@
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
},
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
}
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/dsh-scope": "workspace:*",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:*"
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/dsh-scope": "workspace:*",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3",
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,10 @@
|
||||
* @module @deepseek-ai/dsh-user-questions
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, HarnessError, type ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { userQuestionProjectionDefinition } from './projection.ts'
|
||||
import { TimedQuestionWait } from './timed-wait.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -25,22 +19,17 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
|
||||
import type {
|
||||
AskUserQuestionAnswer, AskUserQuestionRequestEvent, PendingUserQuestion,
|
||||
AskUserQuestionAnswer, AskUserQuestionRequestEvent,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
|
||||
AskUserQuestionOption, PendingUserQuestion, SettledUserQuestion, UserQuestionProjectionView,
|
||||
UserQuestionState,
|
||||
AskUserQuestionOption,
|
||||
} from './types.ts'
|
||||
export { isTimedAskUserQuestionSchema, TIMED_WAIT_PARAMETER } from './projection.ts'
|
||||
|
||||
/** Request for a human answer. */
|
||||
export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {}
|
||||
|
||||
/** Timed ask result returned when the foreground answer window closes. */
|
||||
export type TimedUserQuestionResult = AskUserQuestionAnswer | { pending: true; callId: ToolCallId }
|
||||
|
||||
/** Stable error taxonomy for user-questions failures. */
|
||||
export class UserQuestionError extends HarnessError {
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
@@ -73,144 +62,9 @@ function restoreUserQuestionError(reason: unknown): unknown {
|
||||
}
|
||||
|
||||
/** `ctx.userQuestions`: validation plus the scoped answerer waterfall. */
|
||||
export class UserQuestionService extends TypertRemoteService {
|
||||
static Config = z.object({})
|
||||
private readonly waits = new Map<Agent, Map<ToolCallId, TimedQuestionWait>>()
|
||||
|
||||
export class UserQuestionService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'userQuestions')
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(userQuestionProjectionDefinition)
|
||||
})
|
||||
ctx.effect(() => () => {
|
||||
for (const calls of this.waits.values()) {
|
||||
for (const wait of calls.values()) wait.close(abortedQuestion())
|
||||
}
|
||||
this.waits.clear()
|
||||
}, 'userQuestions: foreground waits')
|
||||
}
|
||||
|
||||
private assertLiveRoot(agent: Agent): void {
|
||||
const agents = this.ctx.get('agents')
|
||||
if (agents === undefined || agents.get(agent.id) !== agent) {
|
||||
throw new UserQuestionError(
|
||||
'human interaction requires the exact live calling agent when an agent is supplied',
|
||||
'CALLER_NOT_LIVE')
|
||||
}
|
||||
if (!agents.roots().includes(agent)) {
|
||||
throw new UserQuestionError(
|
||||
'human interaction is unavailable while the calling agent is owned by another live agent; '
|
||||
+ "include the unresolved question or decision in the child agent's final result",
|
||||
'DELEGATED_CALLER')
|
||||
}
|
||||
}
|
||||
|
||||
private continued(agent: Agent): readonly PendingUserQuestion[] {
|
||||
const state = this.ctx.get('sessionProjections')?.stateOf(agent.session, 'userQuestions')
|
||||
return (state?.questions.active ?? []).filter(question => question.state === 'continued')
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a continued question. The reply is steered into the agent as a
|
||||
* user message whose source names the call; that message is also the
|
||||
* record that closes the question in the projection.
|
||||
* @param agent - Live root agent for the owning Session.
|
||||
* @param callId - Continued question identity.
|
||||
* @param answer - Complete structured answer batch, one item per question of the call.
|
||||
* @returns Whether the question was continued and accepted the answer.
|
||||
* @throws {UserQuestionError} `BAD_ANSWER` when the batch does not name each
|
||||
* question of the call exactly once.
|
||||
*/
|
||||
@Remote
|
||||
answer(agent: Agent, callId: ToolCallId, answer: AskUserQuestionAnswer): boolean {
|
||||
this.assertLiveRoot(agent)
|
||||
const question = this.continued(agent).find(item => item.callId === callId)
|
||||
if (question === undefined) return false
|
||||
// The gateway validated the batch's shape from the type; the model-facing
|
||||
// contract also promises one item per question, which only this owner of
|
||||
// the asked questions can check before the batch reaches the model.
|
||||
const answered = new Set(answer.answers.map(item => item.id))
|
||||
if (answered.size !== answer.answers.length
|
||||
|| question.questions.length !== answer.answers.length
|
||||
|| !question.questions.every(item => answered.has(item.id))) {
|
||||
throw new UserQuestionError(
|
||||
`the answer batch for ${callId} must name each of its ${String(question.questions.length)} questions exactly once`,
|
||||
'BAD_ANSWER')
|
||||
}
|
||||
agent.steer(createUserMessage({
|
||||
source: { kind: 'user-question-reply', callId, outcome: 'answered' },
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
kind: 'answer_to_pending_question', tool: 'ask_user_question', callId,
|
||||
questions: question.questions, answers: answer.answers,
|
||||
}),
|
||||
}],
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Let one answer UI hold a live timed wait. Closing the stream releases its claim.
|
||||
* @param agent - Live root agent owning the question.
|
||||
* @param callId - Foreground tool call to attach to.
|
||||
* @param signal - Remote stream cancellation, including Client disconnect.
|
||||
* @returns One Host-computed remaining duration, or no frames once the wait ended.
|
||||
*/
|
||||
@Remote({ mode: 'stream' })
|
||||
async *attachWait(agent: Agent, callId: ToolCallId, signal: AbortSignal): AsyncIterable<{ remainingMs: number }> {
|
||||
this.assertLiveRoot(agent)
|
||||
const wait = this.waits.get(agent)?.get(callId)
|
||||
if (wait !== undefined) yield* wait.attach(signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground wait whose first settlement the Client decides: the Client
|
||||
* rejects with `ASK_TIMED_OUT` when its countdown ends, and this method maps
|
||||
* that code to the pending result.
|
||||
* @param request - Questions, live owner agent, and abort signal.
|
||||
* @param callId - Tool call identity the Client card is keyed by.
|
||||
* @param timeoutMs - Positive foreground wait in milliseconds.
|
||||
* @returns The answer when it arrives inside the window, otherwise a pending
|
||||
* result, also when no connected Client claimed the request by the deadline.
|
||||
* @throws {UserQuestionError} `BAD_TIMEOUT` for a non-integer, non-positive,
|
||||
* or oversized wait.
|
||||
*/
|
||||
async askTimed(
|
||||
request: AskUserQuestionRequest & { agent: Agent },
|
||||
callId: ToolCallId,
|
||||
timeoutMs: number,
|
||||
): Promise<TimedUserQuestionResult> {
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647) {
|
||||
throw new UserQuestionError('timeout must fit a positive platform timer', 'BAD_TIMEOUT')
|
||||
}
|
||||
this.assertLiveRoot(request.agent)
|
||||
const calls = this.waits.get(request.agent) ?? new Map<ToolCallId, TimedQuestionWait>()
|
||||
if (calls.has(callId)) throw new UserQuestionError('the question call already has a foreground wait', 'DUPLICATE_WAIT')
|
||||
const wait = new TimedQuestionWait(Date.now() + timeoutMs, request.signal,
|
||||
new UserQuestionError('ask_user_question timed out before the user answered', 'ASK_TIMED_OUT'))
|
||||
calls.set(callId, wait)
|
||||
this.waits.set(request.agent, calls)
|
||||
try {
|
||||
try {
|
||||
return await this.ask({ ...request, signal: wait.signal, wait: { callId, timed: true } })
|
||||
} catch (error) {
|
||||
if (wait.signal.aborted) throw wait.signal.reason
|
||||
if (error instanceof UserQuestionError && error.code === 'NO_PROVIDER') {
|
||||
await wait.done
|
||||
throw wait.signal.reason
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UserQuestionError && error.code === 'ASK_TIMED_OUT') return { pending: true, callId }
|
||||
if (wait.signal.aborted) throw abortedQuestion(error)
|
||||
throw error
|
||||
} finally {
|
||||
wait.close(abortedQuestion())
|
||||
calls.delete(callId)
|
||||
if (calls.size === 0) this.waits.delete(request.agent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,7 +91,20 @@ export class UserQuestionService extends TypertRemoteService {
|
||||
throw new UserQuestionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
|
||||
}
|
||||
const agent = request.agent
|
||||
if (agent !== undefined) this.assertLiveRoot(agent)
|
||||
if (agent !== undefined) {
|
||||
const agents = this.ctx.get('agents')
|
||||
if (agents === undefined || agents.get(agent.id) !== agent) {
|
||||
throw new UserQuestionError(
|
||||
'human interaction requires the exact live calling agent when an agent is supplied',
|
||||
'CALLER_NOT_LIVE')
|
||||
}
|
||||
if (!agents.roots().includes(agent)) {
|
||||
throw new UserQuestionError(
|
||||
'human interaction is unavailable while the calling agent is owned by another live agent; '
|
||||
+ "include the unresolved question or decision in the child agent's final result",
|
||||
'DELEGATED_CALLER')
|
||||
}
|
||||
}
|
||||
// A presentation intent asserts two things the types cannot: that the
|
||||
// named approve label is one of this question's own options, and that a
|
||||
// plan-review carries the plan it is a review of. A UI honouring the
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
/** Fold of `ask_user_question` tool events into the answerable question set, and its Session projection. */
|
||||
import { z } from 'zod'
|
||||
import { SessionLogOffset, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import type {} from '@deepseek-ai/dsh-agent/types'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption, PendingUserQuestion, SettledUserQuestion, UserQuestionProjectionView } from './types.ts'
|
||||
|
||||
/**
|
||||
* The pure fold state: whether the request header in effect declares the
|
||||
* timed `ask_user_question` schema, and the question view that schema's
|
||||
* calls build. Calls made under the blocking legacy schema never enter it.
|
||||
*/
|
||||
export interface UserQuestionFold {
|
||||
readonly timed: boolean
|
||||
readonly questions: UserQuestionProjectionView
|
||||
}
|
||||
|
||||
/** Host projection state; `questions` is the wire value, reused whenever an event changes nothing. */
|
||||
export interface UserQuestionProjectionState extends UserQuestionFold {
|
||||
readonly inheritedEventCount: SessionLogOffsetType
|
||||
}
|
||||
|
||||
/** Tool whose calls this projection tracks. */
|
||||
export const ASK_USER_QUESTION_TOOL = 'ask_user_question'
|
||||
|
||||
/**
|
||||
* Model-facing parameter only the timed `ask_user_question` schema declares.
|
||||
* Its presence in the logged request header is what tells the fold that the
|
||||
* `ask_user_question` calls that follow can be continued past a timeout; the
|
||||
* blocking legacy schema never declares it.
|
||||
*/
|
||||
export const TIMED_WAIT_PARAMETER = 'timeout'
|
||||
|
||||
const toolOptionSchema = z.object({
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
}).loose()
|
||||
|
||||
const toolQuestionSchema = z.object({
|
||||
id: z.string(),
|
||||
question: z.string(),
|
||||
header: z.string().optional(),
|
||||
options: z.array(toolOptionSchema).optional(),
|
||||
multi_select: z.boolean().optional(),
|
||||
}).loose()
|
||||
|
||||
const toolArgumentsSchema = z.object({
|
||||
questions: z.array(toolQuestionSchema).min(1),
|
||||
}).loose()
|
||||
|
||||
const optionSchema = z.object({
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
}).strict().transform((option): AskUserQuestionOption => ({
|
||||
label: option.label,
|
||||
...(option.description === undefined ? {} : { description: option.description }),
|
||||
}))
|
||||
|
||||
const questionSchema = z.object({
|
||||
id: z.string(),
|
||||
question: z.string(),
|
||||
detail: z.string().optional(),
|
||||
header: z.string().optional(),
|
||||
options: z.array(optionSchema).optional(),
|
||||
multiSelect: z.boolean().optional(),
|
||||
}).strict().transform((question): AskUserQuestionItem => ({
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
...(question.detail === undefined ? {} : { detail: question.detail }),
|
||||
...(question.header === undefined ? {} : { header: question.header }),
|
||||
...(question.options === undefined ? {} : { options: question.options }),
|
||||
...(question.multiSelect === undefined ? {} : { multiSelect: question.multiSelect }),
|
||||
}))
|
||||
|
||||
const pendingQuestionsSchema: z.ZodType<readonly PendingUserQuestion[]> = z.array(z.object({
|
||||
callId: z.string().min(1).transform(ToolCallId),
|
||||
questions: z.array(questionSchema).min(1),
|
||||
state: z.enum(['open', 'continued']),
|
||||
}).strict()).superRefine((active, context) => {
|
||||
if (new Set(active.map(question => question.callId)).size !== active.length) {
|
||||
context.addIssue({ code: 'custom', message: 'callIds must be unique' })
|
||||
}
|
||||
})
|
||||
|
||||
const answerSchema = z.object({
|
||||
id: z.string(),
|
||||
selected: z.array(z.string()),
|
||||
custom: z.string().optional(),
|
||||
}).strict().transform((answer): AskUserQuestionAnswerItem => ({
|
||||
id: answer.id,
|
||||
selected: answer.selected,
|
||||
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
||||
}))
|
||||
|
||||
const settledQuestionsSchema: z.ZodType<readonly SettledUserQuestion[]> = z.array(z.object({
|
||||
callId: z.string().min(1).transform(ToolCallId),
|
||||
answers: z.array(answerSchema),
|
||||
}).strict())
|
||||
|
||||
const projectionViewSchema: z.ZodType<UserQuestionProjectionView> = z.object({
|
||||
active: pendingQuestionsSchema,
|
||||
settled: settledQuestionsSchema,
|
||||
}).strict()
|
||||
|
||||
/** The `answers` batch as both the in-time tool result and the steered late reply spell it. */
|
||||
const answerBatchSchema = z.object({ answers: z.array(answerSchema) }).loose()
|
||||
|
||||
/** A Session with no timed `ask_user_question` call yet, shared so the wire value stays referentially stable. */
|
||||
const emptyView: UserQuestionProjectionView = { active: [], settled: [] }
|
||||
|
||||
const initialFold: UserQuestionFold = { timed: false, questions: emptyView }
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one logged tool schema is the timed `ask_user_question` tool's.
|
||||
* @param tool - One entry of a request header's assembled tool schemas.
|
||||
* @returns True only for an `ask_user_question` schema whose parameters declare {@link TIMED_WAIT_PARAMETER}.
|
||||
*/
|
||||
export function isTimedAskUserQuestionSchema(tool: Pick<ToolSchema, 'name' | 'parameters'>): boolean {
|
||||
if (tool.name !== ASK_USER_QUESTION_TOOL) return false
|
||||
const properties: unknown = tool.parameters['properties']
|
||||
return isRecord(properties) && TIMED_WAIT_PARAMETER in properties
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the question batch out of logged `ask_user_question` arguments.
|
||||
* @param argumentsText - Raw JSON arguments recorded on the `tool/call` event.
|
||||
* @returns The questions in service vocabulary, or null when the arguments are unreadable.
|
||||
*/
|
||||
export function questionsOf(argumentsText: string): readonly AskUserQuestionItem[] | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(argumentsText)
|
||||
} catch (error) {
|
||||
// Malformed model arguments never reached the tool; there is no card to show.
|
||||
void error
|
||||
return null
|
||||
}
|
||||
const result = toolArgumentsSchema.safeParse(parsed)
|
||||
if (!result.success) return null
|
||||
return result.data.questions.map(question => ({
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
...(question.header === undefined ? {} : { header: question.header }),
|
||||
...(question.options === undefined
|
||||
? {}
|
||||
: {
|
||||
options: question.options.map(option => ({
|
||||
label: option.label,
|
||||
...(option.description === undefined ? {} : { description: option.description }),
|
||||
})),
|
||||
}),
|
||||
...(question.multi_select === undefined ? {} : { multiSelect: question.multi_select }),
|
||||
}))
|
||||
}
|
||||
|
||||
function isPendingResult(message: SessionEvent<'tool/result'>['data']['message']): boolean {
|
||||
const text = message.content.find(block => block.type === 'text')
|
||||
if (text === undefined) return false
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text.text)
|
||||
return typeof parsed === 'object' && parsed !== null && (parsed as { pending?: unknown }).pending === true
|
||||
} catch (error) {
|
||||
// A non-JSON result text is a failure message, never the pending payload.
|
||||
void error
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the answer batch out of one recorded text: a tool result or a late reply.
|
||||
* @param content - Content blocks of the recorded message.
|
||||
* @returns The batch, or null when the text carries none this reader can use.
|
||||
*/
|
||||
function answerBatchOf(content: readonly ContentBlock[]): readonly AskUserQuestionAnswerItem[] | null {
|
||||
const text = content.find(block => block.type === 'text')
|
||||
if (text === undefined) return null
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text.text)
|
||||
} catch (error) {
|
||||
// A failure result, a dismissal, and any other text that is not an answer JSON carries no batch.
|
||||
void error
|
||||
return null
|
||||
}
|
||||
const result = answerBatchSchema.safeParse(parsed)
|
||||
return result.success ? result.data.answers : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Close one answerable question and keep the answers it settled with.
|
||||
* A reply whose call is no longer answerable changes nothing: the splice into
|
||||
* the agent inbox and the durable user message name the same reply, so the
|
||||
* first of the two records it.
|
||||
* @param view - Current question state.
|
||||
* @param callId - Call the result or reply named.
|
||||
* @param answers - The batch it carried; empty when a late reply carried none.
|
||||
* @returns The same view when that call was not answerable, otherwise the updated one.
|
||||
*/
|
||||
function settleQuestion(
|
||||
view: UserQuestionProjectionView,
|
||||
callId: string,
|
||||
answers: readonly AskUserQuestionAnswerItem[],
|
||||
): UserQuestionProjectionView {
|
||||
const question = view.active.find(item => item.callId === callId)
|
||||
if (question === undefined) return view
|
||||
return {
|
||||
active: view.active.filter(item => item.callId !== callId),
|
||||
settled: [...view.settled, { callId: question.callId, answers }],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one Session event to this Session's question fold.
|
||||
* A `request/header` decides, from the assembled tool schemas it records,
|
||||
* whether the `ask_user_question` calls that follow are timed; a call made
|
||||
* under the blocking legacy schema is never tracked, so a Session that only
|
||||
* ever used that tool folds to the empty view. A tracked question stays
|
||||
* answerable as `continued` in exactly two cases: the tool returned the
|
||||
* pending payload, or Session resume repair appended the synthetic
|
||||
* `TOOL_OUTCOME_UNKNOWN` result for a call the process never finished. An
|
||||
* answer batch settles it with that batch; any failure drops it. A late reply
|
||||
* settles a continued question the moment its message enters the agent
|
||||
* inbox, whether answered or dismissed, with the answers it carried.
|
||||
* @param fold - Current fold state.
|
||||
* @param event - Next Session event in append order.
|
||||
* @returns The same fold when the event is unrelated, otherwise the updated one.
|
||||
*/
|
||||
export function applyUserQuestionEvent(fold: UserQuestionFold, event: SessionEvent): UserQuestionFold {
|
||||
const view = fold.questions
|
||||
switch (event.type) {
|
||||
case 'request/header': {
|
||||
// The log records the exact schema list the model saw; a seeded or
|
||||
// tool-less header carries none, which reads as the legacy tool.
|
||||
const tools: unknown = event.data.header.tools
|
||||
const timed = Array.isArray(tools) && tools.some((tool: unknown) =>
|
||||
isRecord(tool) && typeof tool.name === 'string' && isRecord(tool.parameters)
|
||||
&& isTimedAskUserQuestionSchema({ name: tool.name, parameters: tool.parameters }))
|
||||
return timed === fold.timed ? fold : { ...fold, timed }
|
||||
}
|
||||
case 'tool/call': {
|
||||
if (!fold.timed || event.data.name !== ASK_USER_QUESTION_TOOL) return fold
|
||||
const questions = questionsOf(event.data.arguments)
|
||||
if (questions === null) return fold
|
||||
const callId = event.data.callId
|
||||
return {
|
||||
...fold,
|
||||
questions: {
|
||||
...view,
|
||||
active: [...view.active.filter(question => question.callId !== callId), { callId, questions, state: 'open' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'tool/result': {
|
||||
const callId = event.data.message.toolCallId
|
||||
if (!view.active.some(question => question.callId === callId)) return fold
|
||||
if (isPendingResult(event.data.message) || event.data.error?.code === TOOL_OUTCOME_UNKNOWN) {
|
||||
return {
|
||||
...fold,
|
||||
questions: {
|
||||
...view,
|
||||
active: view.active.map(question => question.callId === callId ? { ...question, state: 'continued' } : question),
|
||||
},
|
||||
}
|
||||
}
|
||||
const answers = event.data.error === undefined && event.data.message.isError !== true
|
||||
? answerBatchOf(event.data.message.content)
|
||||
: null
|
||||
return {
|
||||
...fold,
|
||||
questions: answers === null
|
||||
? { ...view, active: view.active.filter(question => question.callId !== callId) }
|
||||
: settleQuestion(view, callId, answers),
|
||||
}
|
||||
}
|
||||
case 'agent/inbox/spliced': {
|
||||
const questions = event.data.inserted.reduce((current, message) => message.source.kind === 'user-question-reply'
|
||||
? settleQuestion(current, message.source.callId, answerBatchOf(message.content) ?? [])
|
||||
: current, view)
|
||||
return questions === view ? fold : { ...fold, questions }
|
||||
}
|
||||
case 'user/message': {
|
||||
const source = event.data.source
|
||||
if (source.kind !== 'user-question-reply') return fold
|
||||
const questions = settleQuestion(view, source.callId, answerBatchOf(event.data.content) ?? [])
|
||||
return questions === view ? fold : { ...fold, questions }
|
||||
}
|
||||
default:
|
||||
return fold
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a whole Session log into its question state.
|
||||
* @param events - Session events in append order.
|
||||
* @returns Open and continued timed calls in ask order, and settled ones in settlement order.
|
||||
*/
|
||||
export function foldUserQuestions(events: readonly SessionEvent[]): UserQuestionProjectionView {
|
||||
return events.reduce(applyUserQuestionEvent, initialFold).questions
|
||||
}
|
||||
|
||||
/** Session projection exposing open, continued, and settled timed questions to every Client. */
|
||||
export const userQuestionProjectionDefinition = {
|
||||
key: 'userQuestions',
|
||||
stateSchema: z.object({
|
||||
inheritedEventCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionLogOffset),
|
||||
timed: z.boolean(),
|
||||
questions: projectionViewSchema,
|
||||
}).strict(),
|
||||
init: (_header, inheritedEventCount) => ({ inheritedEventCount, ...initialFold }),
|
||||
apply: (state, event) => {
|
||||
if (event.seq < state.inheritedEventCount) return state
|
||||
const fold = applyUserQuestionEvent(state, event)
|
||||
return fold === state ? state : { ...state, ...fold }
|
||||
},
|
||||
wire: { viewSchema: projectionViewSchema, view: state => state.questions },
|
||||
stateVersion: 1,
|
||||
} satisfies ProjectionDefinition<'userQuestions', UserQuestionProjectionState>
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionStateMap {
|
||||
userQuestions: UserQuestionProjectionState
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/** Foreground question lifetime and Client claims; durable question state stays in the projection. */
|
||||
|
||||
/** One live foreground wait, counted by the Host only while no answer UI holds it. */
|
||||
export class TimedQuestionWait {
|
||||
private readonly controller = new AbortController()
|
||||
private readonly completion = Promise.withResolvers<void>()
|
||||
private readonly claims = new Set<() => void>()
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
/**
|
||||
* @param deadline - Host-clock deadline used while no Client holds the wait.
|
||||
* @param parent - Calling Turn's cancellation signal.
|
||||
* @param timeout - Business error used to settle an unattended wait.
|
||||
*/
|
||||
constructor(
|
||||
readonly deadline: number,
|
||||
private readonly parent: AbortSignal | undefined,
|
||||
private readonly timeout: Error,
|
||||
) {
|
||||
parent?.addEventListener('abort', this.parentAborted, { once: true })
|
||||
if (parent?.aborted === true) this.parentAborted()
|
||||
else this.schedule()
|
||||
}
|
||||
|
||||
/** Cancellation shared with the foreground waterfall, not with the calling Turn. */
|
||||
get signal(): AbortSignal { return this.controller.signal }
|
||||
|
||||
/** Settles when the wait is cancelled, expires, or is disposed. */
|
||||
get done(): Promise<void> { return this.completion.promise }
|
||||
|
||||
private readonly parentAborted = (): void => { this.close(this.parent?.reason) }
|
||||
|
||||
private schedule(): void {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
if (this.signal.aborted || this.claims.size > 0) return
|
||||
this.timer = setTimeout(() => { this.close(this.timeout) }, Math.max(0, this.deadline - Date.now()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the wait for one answer UI until its stream closes or the question settles.
|
||||
* @param signal - This business stream's cancellation lifetime.
|
||||
* @returns One remaining-duration frame; completion releases the claim.
|
||||
*/
|
||||
async *attach(signal: AbortSignal): AsyncIterable<{ remainingMs: number }> {
|
||||
if (signal.aborted || this.signal.aborted) return
|
||||
if (this.claims.size === 0 && Date.now() >= this.deadline) {
|
||||
this.close(this.timeout)
|
||||
return
|
||||
}
|
||||
const ended = Promise.withResolvers<void>()
|
||||
const release = (): void => {
|
||||
if (!this.claims.delete(release)) return
|
||||
signal.removeEventListener('abort', release)
|
||||
this.signal.removeEventListener('abort', release)
|
||||
ended.resolve()
|
||||
this.schedule()
|
||||
}
|
||||
this.claims.add(release)
|
||||
signal.addEventListener('abort', release, { once: true })
|
||||
this.signal.addEventListener('abort', release, { once: true })
|
||||
this.schedule()
|
||||
try {
|
||||
yield { remainingMs: Math.max(0, this.deadline - Date.now()) }
|
||||
await ended.promise
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release timers, parent cancellation, and all Client claims.
|
||||
* @param reason - Cancellation delivered to the foreground waterfall.
|
||||
*/
|
||||
close(reason: unknown): void {
|
||||
if (this.signal.aborted) return
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
this.parent?.removeEventListener('abort', this.parentAborted)
|
||||
this.controller.abort(reason)
|
||||
this.completion.resolve()
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent/types'
|
||||
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type {} from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection/types'
|
||||
|
||||
/** One selectable answer offered to the user. */
|
||||
export interface AskUserQuestionOption {
|
||||
@@ -68,66 +66,6 @@ export interface AskUserQuestionAnswer {
|
||||
answers: AskUserQuestionAnswerItem[]
|
||||
}
|
||||
|
||||
/** `open` while the tool call may still return the answer; `continued` once the answer can only arrive as a new turn. */
|
||||
export type UserQuestionState = 'open' | 'continued'
|
||||
|
||||
/** One unanswered timed `ask_user_question` call reconstructed from the Session log. */
|
||||
export interface PendingUserQuestion {
|
||||
readonly callId: ToolCallId
|
||||
readonly questions: readonly AskUserQuestionItem[]
|
||||
readonly state: UserQuestionState
|
||||
}
|
||||
|
||||
/**
|
||||
* One timed `ask_user_question` call and the answers it settled with: the
|
||||
* batch its own result carried when the user answered inside the window,
|
||||
* otherwise the batch its late reply carried, because that call's own result
|
||||
* recorded the timeout. A transcript row reads what the user finally answered
|
||||
* from here, and only a call listed here was a timed one.
|
||||
*/
|
||||
export interface SettledUserQuestion {
|
||||
/** The settled call. */
|
||||
readonly callId: ToolCallId
|
||||
/** The recorded batch, one entry per question; empty when the late reply carried none. */
|
||||
readonly answers: readonly AskUserQuestionAnswerItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Both halves of one Session's timed `ask_user_question` state, as every
|
||||
* Client reads them. A call made while the blocking legacy tool was in
|
||||
* effect appears in neither half.
|
||||
*/
|
||||
export interface UserQuestionProjectionView {
|
||||
/** Calls that can still take an answer, in ask order. */
|
||||
readonly active: readonly PendingUserQuestion[]
|
||||
/** Calls an answer settled, in settlement order. */
|
||||
readonly settled: readonly SettledUserQuestion[]
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
/**
|
||||
* Late reply to a continued `ask_user_question` call, steered into the
|
||||
* agent by `dsh-user-questions` as the answer batch (`answered`).
|
||||
* `dismissed` has no producer: closing a question panel is a Client-local
|
||||
* hide that persists nothing, and an unanswered question ends through its
|
||||
* timeout instead. Sessions recorded before that change still carry it, so
|
||||
* readers keep both outcomes. Readers preserve the message without this
|
||||
* producer; only the `userQuestions` projection reads the kind, to close
|
||||
* the question and to record the answers it carried.
|
||||
* @persistenceAttribution
|
||||
*/
|
||||
'user-question-reply': { kind: 'user-question-reply'; callId: ToolCallId; outcome: 'answered' | 'dismissed' }
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Timed questions that remain answerable in this Session, and the ones a late reply settled. */
|
||||
userQuestions: UserQuestionProjectionView
|
||||
}
|
||||
}
|
||||
|
||||
/** Client-safe payload declared for the user-question answerer waterfall. */
|
||||
export interface AskUserQuestionRequestEvent {
|
||||
/** Questions to display. */
|
||||
@@ -136,16 +74,6 @@ export interface AskUserQuestionRequestEvent {
|
||||
agent?: Agent
|
||||
/** Cancellation lifetime of the pending request. */
|
||||
signal?: AbortSignal
|
||||
/**
|
||||
* Tool call the Client card is keyed by. Timed answerers attach to the
|
||||
* business wait stream before starting their local countdown.
|
||||
*/
|
||||
wait?: {
|
||||
/** Tool call the Client card is keyed by. */
|
||||
callId: ToolCallId
|
||||
/** True for a foreground wait that requires a Client claim; absent for indefinite waits. */
|
||||
timed?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionLogOffset, SessionSeq, TOOL_OUTCOME_UNKNOWN, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { createToolResultMessage, createUserMessage, ToolCallId, type ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
applyUserQuestionEvent, foldUserQuestions, isTimedAskUserQuestionSchema, questionsOf, TIMED_WAIT_PARAMETER,
|
||||
userQuestionProjectionDefinition,
|
||||
} from '../src/projection.ts'
|
||||
|
||||
const callId = ToolCallId('call_ask_1')
|
||||
const toolArguments = JSON.stringify({
|
||||
questions: [{
|
||||
id: 'scope',
|
||||
question: 'Which scope?',
|
||||
header: 'Scope',
|
||||
options: [{ label: 'Tool only', description: 'Only the tool package.' }, { label: 'Everything' }],
|
||||
multi_select: true,
|
||||
}],
|
||||
})
|
||||
const questions = [{
|
||||
id: 'scope',
|
||||
question: 'Which scope?',
|
||||
header: 'Scope',
|
||||
options: [{ label: 'Tool only', description: 'Only the tool package.' }, { label: 'Everything' }],
|
||||
multiSelect: true,
|
||||
}]
|
||||
|
||||
function event<K extends SessionEvent['type']>(seq: number, type: K, data: SessionEvent<K>['data']): SessionEvent {
|
||||
return { type, seq: SessionSeq(seq), time: seq, data } as SessionEvent
|
||||
}
|
||||
|
||||
/** The `ask_user_question` schema as the blocking legacy tool declares it: no wait parameter. */
|
||||
const legacySchema: ToolSchema = {
|
||||
name: 'ask_user_question',
|
||||
description: 'Ask the user a concise question.',
|
||||
parameters: { type: 'object', properties: { questions: { type: 'array' } } },
|
||||
}
|
||||
|
||||
/** The `ask_user_question` schema as the timed tool declares it: the wait parameter marks it. */
|
||||
const timedSchema: ToolSchema = {
|
||||
name: 'ask_user_question',
|
||||
description: 'Ask brief questions.',
|
||||
parameters: { type: 'object', properties: { questions: { type: 'array' }, [TIMED_WAIT_PARAMETER]: { type: 'integer' } } },
|
||||
}
|
||||
|
||||
const bashSchema: ToolSchema = { name: 'bash', description: 'Run a command.', parameters: { type: 'object', properties: {} } }
|
||||
|
||||
/** The request header the model saw before it asked. */
|
||||
function header(seq: number, tools: ToolSchema[] | undefined): SessionEvent {
|
||||
return event(seq, 'request/header', {
|
||||
header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, ...(tools === undefined ? {} : { tools }) },
|
||||
reason: 'initial',
|
||||
})
|
||||
}
|
||||
|
||||
const timedHeader = header(0, [bashSchema, timedSchema])
|
||||
const legacyHeader = header(0, [bashSchema, legacySchema])
|
||||
|
||||
const asked = event(1, 'tool/call', { turn: 1, step: 1, callId, name: 'ask_user_question', arguments: toolArguments })
|
||||
|
||||
function resulted(text: string, error?: { name: string; code: string }): SessionEvent {
|
||||
return event(2, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({ callId, content: [{ type: 'text', text }], isError: error !== undefined }),
|
||||
...(error === undefined ? {} : { error }),
|
||||
})
|
||||
}
|
||||
|
||||
const reply = (outcome: 'answered' | 'dismissed', text = '{}') => createUserMessage({
|
||||
source: { kind: 'user-question-reply', callId, outcome },
|
||||
content: [{ type: 'text', text }],
|
||||
})
|
||||
|
||||
const answerBatch = [{ id: 'scope', selected: ['Tool only'], custom: 'also docs' }]
|
||||
const answeredText = JSON.stringify({
|
||||
kind: 'answer_to_pending_question', tool: 'ask_user_question', callId,
|
||||
questions, answers: answerBatch,
|
||||
})
|
||||
|
||||
const empty = { active: [], settled: [] }
|
||||
|
||||
describe('userQuestions projection fold', () => {
|
||||
it('recognises the timed tool schema by its wait parameter alone', () => {
|
||||
expect(isTimedAskUserQuestionSchema(timedSchema)).toBe(true)
|
||||
expect(isTimedAskUserQuestionSchema(legacySchema)).toBe(false)
|
||||
expect(isTimedAskUserQuestionSchema({ ...timedSchema, name: 'bash' })).toBe(false)
|
||||
expect(isTimedAskUserQuestionSchema({ name: 'ask_user_question', parameters: { type: 'object' } })).toBe(false)
|
||||
})
|
||||
|
||||
it('opens a question asked under the timed schema and maps the tool vocabulary', () => {
|
||||
expect(foldUserQuestions([timedHeader, asked])).toEqual({ active: [{ callId, questions, state: 'open' }], settled: [] })
|
||||
})
|
||||
|
||||
it('never tracks a call made under the blocking legacy tool, so a legacy Session folds to the empty view', () => {
|
||||
const legacyLog = [legacyHeader, asked]
|
||||
expect(foldUserQuestions(legacyLog)).toEqual(empty)
|
||||
expect(foldUserQuestions([...legacyLog, resulted('{"answers":[{"id":"scope","selected":["Tool only"]}]}')])).toEqual(empty)
|
||||
expect(foldUserQuestions([
|
||||
...legacyLog,
|
||||
resulted('The tool call was interrupted after it was recorded.', { name: 'SessionFormatError', code: TOOL_OUTCOME_UNKNOWN }),
|
||||
])).toEqual(empty)
|
||||
// No header at all, a header without tools, and a seeded header whose
|
||||
// tools are a placeholder string all read as the legacy tool.
|
||||
expect(foldUserQuestions([asked])).toEqual(empty)
|
||||
expect(foldUserQuestions([header(0, undefined), asked])).toEqual(empty)
|
||||
expect(foldUserQuestions([
|
||||
event(0, 'request/header', { header: { config: {}, tools: '{{tools}}' }, reason: 'initial' } as never),
|
||||
asked,
|
||||
])).toEqual(empty)
|
||||
})
|
||||
|
||||
it('follows the latest header when the composed tool changes mid-Session', () => {
|
||||
const later = event(3, 'tool/call', { turn: 2, step: 1, callId: ToolCallId('call_ask_2'), name: 'ask_user_question', arguments: toolArguments })
|
||||
expect(foldUserQuestions([legacyHeader, asked, header(2, [timedSchema]), later])).toEqual({
|
||||
active: [{ callId: ToolCallId('call_ask_2'), questions, state: 'open' }], settled: [],
|
||||
})
|
||||
expect(foldUserQuestions([timedHeader, asked, header(2, [legacySchema]), later])).toEqual({
|
||||
active: [{ callId, questions, state: 'open' }], settled: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('settles an in-time answer batch with that batch and drops every failure a live Host records', () => {
|
||||
expect(foldUserQuestions([timedHeader, asked, resulted('{"answers":[{"id":"scope","selected":["Tool only"]}]}')]))
|
||||
.toEqual({ active: [], settled: [{ callId, answers: [{ id: 'scope', selected: ['Tool only'] }] }] })
|
||||
expect(foldUserQuestions([timedHeader, asked, resulted('cancelled', { name: 'UserQuestionError', code: 'ASK_CANCELLED' })])).toEqual(empty)
|
||||
expect(foldUserQuestions([timedHeader, asked, resulted('aborted', { name: 'UserQuestionError', code: 'ASK_ABORTED' })])).toEqual(empty)
|
||||
expect(foldUserQuestions([timedHeader, asked, resulted('boom', { name: 'Error', code: 'UNEXPECTED' })])).toEqual(empty)
|
||||
expect(foldUserQuestions([timedHeader, asked, resulted('not json at all')])).toEqual(empty)
|
||||
expect(foldUserQuestions([timedHeader, asked, resulted('{"answers":"nope"}')])).toEqual(empty)
|
||||
// A batch spelled inside an error result is a failure, whatever its text says.
|
||||
expect(foldUserQuestions([timedHeader, asked, event(2, 'tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({ callId, content: [{ type: 'text', text: '{"answers":[]}' }], isError: true }),
|
||||
})])).toEqual(empty)
|
||||
})
|
||||
|
||||
it('keeps a pending call, or one the process never finished, answerable as continued', () => {
|
||||
const continued = { active: [{ callId, questions, state: 'continued' }], settled: [] }
|
||||
expect(foldUserQuestions([timedHeader, asked, resulted('{"pending":true,"callId":"call_ask_1"}')])).toEqual(continued)
|
||||
expect(foldUserQuestions([
|
||||
timedHeader,
|
||||
asked,
|
||||
resulted('The tool call was interrupted after it was recorded.', { name: 'SessionFormatError', code: TOOL_OUTCOME_UNKNOWN }),
|
||||
])).toEqual(continued)
|
||||
})
|
||||
|
||||
it('closes a continued question when its reply enters the inbox or is claimed', () => {
|
||||
const continued = [timedHeader, asked, resulted('{"pending":true,"callId":"call_ask_1"}')]
|
||||
const settled = { active: [], settled: [{ callId, answers: [] }] }
|
||||
expect(foldUserQuestions([
|
||||
...continued,
|
||||
event(3, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [reply('answered')] }),
|
||||
])).toEqual(settled)
|
||||
expect(foldUserQuestions([
|
||||
...continued,
|
||||
event(3, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [reply('dismissed')] }),
|
||||
])).toEqual(settled)
|
||||
expect(foldUserQuestions([...continued, event(3, 'user/message', reply('answered'))])).toEqual(settled)
|
||||
})
|
||||
|
||||
it('records the answers one reply carried, exactly once across its splice and its own event', () => {
|
||||
const replied = reply('answered', answeredText)
|
||||
const settled = { active: [], settled: [{ callId, answers: answerBatch }] }
|
||||
const continued = [timedHeader, asked, resulted('{"pending":true,"callId":"call_ask_1"}')]
|
||||
expect(foldUserQuestions([...continued, event(3, 'user/message', replied)])).toEqual(settled)
|
||||
expect(foldUserQuestions([
|
||||
...continued,
|
||||
event(3, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [replied] }),
|
||||
event(4, 'user/message', replied),
|
||||
])).toEqual(settled)
|
||||
})
|
||||
|
||||
it('settles with no answers when the reply text carries none this reader can use', () => {
|
||||
const continued = [timedHeader, asked, resulted('{"pending":true,"callId":"call_ask_1"}')]
|
||||
const settled = { active: [], settled: [{ callId, answers: [] }] }
|
||||
expect(foldUserQuestions([...continued, event(3, 'user/message', reply('answered', 'no json here'))])).toEqual(settled)
|
||||
expect(foldUserQuestions([...continued, event(3, 'user/message', reply('answered', '{"answers":"nope"}'))])).toEqual(settled)
|
||||
expect(foldUserQuestions([...continued, event(3, 'user/message', createUserMessage({
|
||||
source: { kind: 'user-question-reply', callId, outcome: 'answered' },
|
||||
content: [],
|
||||
}))])).toEqual(settled)
|
||||
})
|
||||
|
||||
it('returns the same fold for unrelated events, unreadable calls, and an unchanged header', () => {
|
||||
const fold = [timedHeader, asked].reduce(applyUserQuestionEvent, { timed: false, questions: empty })
|
||||
expect(applyUserQuestionEvent(fold, event(5, 'turn/start', { turn: 2 }))).toBe(fold)
|
||||
expect(applyUserQuestionEvent(fold, header(5, [timedSchema]))).toBe(fold)
|
||||
expect(applyUserQuestionEvent(fold, event(5, 'tool/call', {
|
||||
turn: 1, step: 2, callId: ToolCallId('other'), name: 'bash', arguments: '{}',
|
||||
}))).toBe(fold)
|
||||
expect(applyUserQuestionEvent(fold, event(5, 'tool/call', {
|
||||
turn: 1, step: 2, callId: ToolCallId('broken'), name: 'ask_user_question', arguments: '{not json',
|
||||
}))).toBe(fold)
|
||||
expect(applyUserQuestionEvent(fold, event(5, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text: 'hi' }] })],
|
||||
}))).toBe(fold)
|
||||
expect(applyUserQuestionEvent(fold, event(5, 'user/message', createUserMessage({
|
||||
source: { kind: 'user' }, content: [{ type: 'text', text: 'hi' }],
|
||||
})))).toBe(fold)
|
||||
expect(applyUserQuestionEvent(fold, event(5, 'user/message', createUserMessage({
|
||||
source: { kind: 'user-question-reply', callId: ToolCallId('other'), outcome: 'answered' },
|
||||
content: [{ type: 'text', text: '{}' }],
|
||||
})))).toBe(fold)
|
||||
expect(applyUserQuestionEvent(fold, event(2, 'tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({ callId: ToolCallId('other'), content: [{ type: 'text', text: '{}' }], isError: false }),
|
||||
}))).toBe(fold)
|
||||
})
|
||||
|
||||
it('tracks several timed calls at once and re-asks under a reused call id in place', () => {
|
||||
const otherId = ToolCallId('call_ask_2')
|
||||
const other = event(3, 'tool/call', {
|
||||
turn: 1, step: 2, callId: otherId, name: 'ask_user_question',
|
||||
arguments: '{"questions":[{"id":"name","question":"Name?"}]}',
|
||||
})
|
||||
const both = [timedHeader, asked, other].reduce(applyUserQuestionEvent, { timed: false, questions: empty })
|
||||
expect(both.questions.active.map(question => question.callId)).toEqual([callId, otherId])
|
||||
|
||||
const continued = applyUserQuestionEvent(both, resulted('{"pending":true,"callId":"call_ask_1"}'))
|
||||
expect(continued.questions.active.map(question => question.state)).toEqual(['continued', 'open'])
|
||||
|
||||
const reasked = applyUserQuestionEvent(continued, asked)
|
||||
expect(reasked.questions.active.map(question => [question.callId, question.state]))
|
||||
.toEqual([[otherId, 'open'], [callId, 'open']])
|
||||
})
|
||||
|
||||
it('closes a tracked call whose result carries no text at all', () => {
|
||||
expect(foldUserQuestions([timedHeader, asked, event(2, 'tool/result', {
|
||||
turn: 1, step: 1, message: createToolResultMessage({ callId, content: [], isError: false }),
|
||||
})])).toEqual(empty)
|
||||
})
|
||||
|
||||
it('restores a checkpointed state in service vocabulary and refuses duplicate call ids', () => {
|
||||
const full = {
|
||||
callId: 'call_ask_1',
|
||||
questions: [
|
||||
{
|
||||
id: 'plan', question: 'Approve?', detail: '# Plan', header: 'Review',
|
||||
options: [{ label: 'Approve', description: 'Ship it.' }, { label: 'Refuse' }], multiSelect: false,
|
||||
},
|
||||
{ id: 'bare', question: 'Anything else?' },
|
||||
],
|
||||
state: 'continued',
|
||||
}
|
||||
const view = { active: [full], settled: [{ callId: 'call_ask_0', answers: answerBatch }] }
|
||||
const restored = userQuestionProjectionDefinition.stateSchema.parse({ inheritedEventCount: 3, timed: true, questions: view })
|
||||
expect(restored).toEqual({ inheritedEventCount: SessionLogOffset(3), timed: true, questions: view })
|
||||
expect(restored.questions.active[0]?.questions[1]).not.toHaveProperty('options')
|
||||
|
||||
const { viewSchema } = userQuestionProjectionDefinition.wire
|
||||
expect(() => viewSchema.parse({ active: [full, { ...full, state: 'open' }], settled: [] })).toThrow(/callIds must be unique/)
|
||||
expect(() => viewSchema.parse({ active: [{ ...full, callId: '' }], settled: [] })).toThrow()
|
||||
})
|
||||
|
||||
it('reads only well-formed question batches out of the arguments', () => {
|
||||
expect(questionsOf('{"questions":[]}')).toBeNull()
|
||||
expect(questionsOf('{"questions":[{"id":"x"}]}')).toBeNull()
|
||||
expect(questionsOf('[]')).toBeNull()
|
||||
expect(questionsOf('{"questions":[{"id":"x","question":"Q?"}]}')).toEqual([{ id: 'x', question: 'Q?' }])
|
||||
})
|
||||
|
||||
it('does not inherit a parent Session question into a fork and exposes the whole view on the wire', () => {
|
||||
const inherited = userQuestionProjectionDefinition.init({} as never, SessionLogOffset(2))
|
||||
expect(userQuestionProjectionDefinition.apply(inherited, timedHeader)).toBe(inherited)
|
||||
expect(userQuestionProjectionDefinition.apply(inherited, asked)).toBe(inherited)
|
||||
|
||||
const fresh = userQuestionProjectionDefinition.init({} as never, SessionLogOffset(0))
|
||||
const armed = userQuestionProjectionDefinition.apply(fresh, timedHeader)
|
||||
expect(armed).toMatchObject({ inheritedEventCount: 0, timed: true })
|
||||
expect(userQuestionProjectionDefinition.wire.view(armed)).toBe(userQuestionProjectionDefinition.wire.view(fresh))
|
||||
const opened = userQuestionProjectionDefinition.apply(armed, asked)
|
||||
expect(opened).not.toBe(armed)
|
||||
expect(userQuestionProjectionDefinition.wire.view(opened))
|
||||
.toEqual({ active: [{ callId, questions, state: 'open' }], settled: [] })
|
||||
expect(userQuestionProjectionDefinition.apply(opened, event(5, 'turn/start', { turn: 2 }))).toBe(opened)
|
||||
expect(userQuestionProjectionDefinition.stateVersion).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TimedQuestionWait } from '../src/timed-wait.ts'
|
||||
|
||||
const waits: TimedQuestionWait[] = []
|
||||
afterEach(() => {
|
||||
for (const wait of waits.splice(0)) wait.close(new Error('test ended'))
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function start(parent?: AbortSignal) {
|
||||
vi.useFakeTimers()
|
||||
const timeout = new Error('question timed out')
|
||||
const wait = new TimedQuestionWait(Date.now() + 5_000, parent, timeout)
|
||||
waits.push(wait)
|
||||
return { wait, timeout }
|
||||
}
|
||||
|
||||
describe('foreground question claims', () => {
|
||||
it('expires with no answer UI and releases its timer', async () => {
|
||||
const { wait, timeout } = start()
|
||||
await vi.advanceTimersByTimeAsync(4_999)
|
||||
expect(wait.signal.aborted).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await wait.done
|
||||
expect(wait.signal.reason).toBe(timeout)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a claimed wait open beyond the Host deadline and expires after the last claim leaves', async () => {
|
||||
const { wait, timeout } = start()
|
||||
const first = new AbortController()
|
||||
const second = new AbortController()
|
||||
const a = wait.attach(first.signal)[Symbol.asyncIterator]()
|
||||
const b = wait.attach(second.signal)[Symbol.asyncIterator]()
|
||||
expect(await a.next()).toEqual({ done: false, value: { remainingMs: 5_000 } })
|
||||
const aEnd = a.next()
|
||||
await b.next()
|
||||
const bEnd = b.next()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
first.abort()
|
||||
await aEnd
|
||||
expect(wait.signal.aborted).toBe(false)
|
||||
second.abort()
|
||||
await bEnd
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(wait.signal.reason).toBe(timeout)
|
||||
})
|
||||
|
||||
it('resumes the original remaining duration after a claim disconnects', async () => {
|
||||
const { wait } = start()
|
||||
const client = new AbortController()
|
||||
const stream = wait.attach(client.signal)[Symbol.asyncIterator]()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(await stream.next()).toEqual({ done: false, value: { remainingMs: 4_000 } })
|
||||
const end = stream.next()
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
client.abort()
|
||||
await end
|
||||
await vi.advanceTimersByTimeAsync(1_999)
|
||||
expect(wait.signal.aborted).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(wait.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('releases a claim when its consumer returns', async () => {
|
||||
const { wait } = start()
|
||||
const stream = wait.attach(new AbortController().signal)[Symbol.asyncIterator]()
|
||||
await stream.next()
|
||||
await stream.return?.()
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(wait.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('ends claim streams with the parent cancellation without expiring the parent itself', async () => {
|
||||
const parent = new AbortController()
|
||||
const { wait } = start(parent.signal)
|
||||
const stream = wait.attach(new AbortController().signal)[Symbol.asyncIterator]()
|
||||
await stream.next()
|
||||
const end = stream.next()
|
||||
const reason = new Error('turn ended')
|
||||
parent.abort(reason)
|
||||
expect(await end).toEqual({ done: true, value: undefined })
|
||||
expect(wait.signal.reason).toBe(reason)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
const otherParent = new AbortController()
|
||||
const other = start(otherParent.signal).wait
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(other.signal.aborted).toBe(true)
|
||||
expect(otherParent.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
it('does not claim a cancelled or expired wait', async () => {
|
||||
const parent = new AbortController()
|
||||
parent.abort(new Error('already ended'))
|
||||
const { wait } = start(parent.signal)
|
||||
expect(await wait.attach(new AbortController().signal)[Symbol.asyncIterator]().next()).toMatchObject({ done: true })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
const live = start().wait
|
||||
const cancelled = new AbortController()
|
||||
cancelled.abort()
|
||||
expect(await live.attach(cancelled.signal)[Symbol.asyncIterator]().next()).toMatchObject({ done: true })
|
||||
vi.setSystemTime(Date.now() + 6_000)
|
||||
expect(await live.attach(new AbortController().signal)[Symbol.asyncIterator]().next()).toMatchObject({ done: true })
|
||||
expect(live.signal.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,11 @@
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import UserQuestionService, {
|
||||
TIMED_WAIT_PARAMETER,
|
||||
UserQuestionError,
|
||||
type AskUserQuestionAnswer,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-questions'
|
||||
import { createToolResultMessage, ToolCallId, type ToolSchema, type UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
interface QuestionAnswerer {
|
||||
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
|
||||
@@ -356,364 +352,3 @@ describe('UserQuestionService', () => {
|
||||
expect(p.seen[0]?.questions[1]?.intent).toEqual(intent)
|
||||
})
|
||||
})
|
||||
|
||||
/** Rejection as the Client runtime ships it across the wire: a plain Error carrying the typed name and code. */
|
||||
function wireRejection(code: string): Error {
|
||||
const error = new Error(`client rejected with ${code}`) as Error & { code: string }
|
||||
error.name = 'UserQuestionError'
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
interface LiveAgent extends Agent {
|
||||
steer: ReturnType<typeof vi.fn<(message: UserMessage) => void>>
|
||||
inject: ReturnType<typeof vi.fn<(message: UserMessage) => void>>
|
||||
}
|
||||
|
||||
function liveAgent(id: string): LiveAgent {
|
||||
const steer = vi.fn<(message: UserMessage) => void>()
|
||||
const inject = vi.fn<(message: UserMessage) => void>()
|
||||
return Object.assign(stubAgent(id), {
|
||||
session: Session.create(SessionId(id)),
|
||||
steer,
|
||||
inject,
|
||||
})
|
||||
}
|
||||
|
||||
async function timedContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const timedQuestions = [{ id: 'scope', question: 'Which scope?', options: [{ label: 'Tool only' }] }]
|
||||
const timedCallId = ToolCallId('ask-timed')
|
||||
|
||||
function replyText(message: UserMessage): unknown {
|
||||
const block = message.content[0]
|
||||
if (block === undefined || block.type !== 'text') throw new Error('expected a text reply')
|
||||
return JSON.parse(block.text)
|
||||
}
|
||||
|
||||
describe('askTimed', () => {
|
||||
it('keeps sibling waits independent and cancels the remaining wait when the service unloads', async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const ctx = await timedContext()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
const agent = liveAgent('timed-sibling-waits')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
const service = ctx.userQuestions
|
||||
const laterCallId = ToolCallId('ask-later')
|
||||
const first = service.askTimed({ agent, questions: timedQuestions }, timedCallId, 1_000)
|
||||
const later = service.askTimed({ agent, questions: timedQuestions }, laterCallId, 2_000)
|
||||
const cancelled = expect(later).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await expect(first).resolves.toEqual({ pending: true, callId: timedCallId })
|
||||
const claim = service.attachWait(agent, laterCallId, new AbortController().signal)[Symbol.asyncIterator]()
|
||||
expect(await claim.next()).toEqual({ done: false, value: { remainingMs: 1_000 } })
|
||||
const end = claim.next()
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
await cancelled
|
||||
expect(await end).toEqual({ done: true, value: undefined })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('expires a forwarded request even when no Client ever responds or delegates', async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const ctx = await timedContext()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
const agent = liveAgent('timed-no-client')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
const turn = new AbortController()
|
||||
let forwardedSignal: AbortSignal | undefined
|
||||
registerAnswerer(ctx, {
|
||||
ask: request => new Promise((_resolve, reject) => {
|
||||
forwardedSignal = request.signal
|
||||
request.signal?.addEventListener('abort', () => {
|
||||
reject(new Error('forwarded request aborted', { cause: request.signal?.reason }))
|
||||
}, { once: true })
|
||||
}),
|
||||
})
|
||||
const result = ctx.userQuestions.askTimed(
|
||||
{ agent, questions: timedQuestions, signal: turn.signal }, timedCallId, 5_000,
|
||||
)
|
||||
expect(forwardedSignal).not.toBe(turn.signal)
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
await expect(result).resolves.toEqual({ pending: true, callId: timedCallId })
|
||||
expect(forwardedSignal?.aborted).toBe(true)
|
||||
expect(turn.signal.aborted).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps a Client-claimed wait open and closes its stream when the answer settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
onTestFinished(() => { vi.useRealTimers() })
|
||||
const ctx = await timedContext()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
const agent = liveAgent('timed-claimed')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
const answer = Promise.withResolvers<AskUserQuestionAnswer>()
|
||||
registerAnswerer(ctx, { ask: () => answer.promise })
|
||||
let settled = false
|
||||
const result = ctx.userQuestions.askTimed({ agent, questions: timedQuestions }, timedCallId, 5_000)
|
||||
.finally(() => { settled = true })
|
||||
const stream = ctx.userQuestions.attachWait(agent, timedCallId, new AbortController().signal)[Symbol.asyncIterator]()
|
||||
expect(await stream.next()).toEqual({ done: false, value: { remainingMs: 5_000 } })
|
||||
const end = stream.next()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(settled).toBe(false)
|
||||
await expect(ctx.userQuestions.askTimed({ agent, questions: timedQuestions }, timedCallId, 5_000))
|
||||
.rejects.toMatchObject({ code: 'DUPLICATE_WAIT' })
|
||||
const batch = { answers: [{ id: 'scope', selected: ['Tool only'] }] }
|
||||
answer.resolve(batch)
|
||||
await expect(result).resolves.toEqual(batch)
|
||||
expect(await end).toEqual({ done: true, value: undefined })
|
||||
expect(await ctx.userQuestions.attachWait(agent, timedCallId, new AbortController().signal)
|
||||
[Symbol.asyncIterator]().next()).toMatchObject({ done: true })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('returns the answer when a Client settles the request inside the window', async () => {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('timed-answered')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
registerAnswerer(ctx, {
|
||||
ask: async (request) => { seen.push(request); return { answers: [{ id: 'scope', selected: ['Tool only'] }] } },
|
||||
})
|
||||
const result = await ctx.userQuestions.askTimed({ agent, questions: timedQuestions }, timedCallId, 120_000)
|
||||
|
||||
expect(result).toEqual({ answers: [{ id: 'scope', selected: ['Tool only'] }] })
|
||||
expect(seen[0]?.wait?.callId).toBe(timedCallId)
|
||||
expect(seen[0]?.wait?.timed).toBe(true)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('maps the Client deadline rejection to the pending result and rethrows any other code', async () => {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('timed-expired')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
let code = 'ASK_TIMED_OUT'
|
||||
registerAnswerer(ctx, { ask: () => Promise.reject(wireRejection(code)) })
|
||||
|
||||
await expect(ctx.userQuestions.askTimed({ agent, questions: timedQuestions }, timedCallId, 120_000))
|
||||
.resolves.toEqual({ pending: true, callId: timedCallId })
|
||||
code = 'ASK_CANCELLED'
|
||||
await expect(ctx.userQuestions.askTimed({ agent, questions: timedQuestions }, timedCallId, 120_000))
|
||||
.rejects.toMatchObject({ name: 'UserQuestionError', code: 'ASK_CANCELLED' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('holds an unclaimed request until the deadline and then returns pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('timed-unclaimed')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
let settled = false
|
||||
const result = ctx.userQuestions.askTimed({ agent, questions: timedQuestions }, timedCallId, 5_000)
|
||||
.finally(() => { settled = true })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999)
|
||||
expect(settled).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await expect(result).resolves.toEqual({ pending: true, callId: timedCallId })
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts the hold with the turn signal', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('timed-aborted-hold')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
const controller = new AbortController()
|
||||
const result = ctx.userQuestions.askTimed(
|
||||
{ agent, questions: timedQuestions, signal: controller.signal }, timedCallId, 5_000,
|
||||
)
|
||||
const rejection = expect(result).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
controller.abort()
|
||||
|
||||
await rejection
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rethrows a provider failure that is not a question error', async () => {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('timed-provider-crash')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
registerAnswerer(ctx, { ask: () => Promise.reject(new TypeError('provider crashed')) })
|
||||
|
||||
await expect(ctx.userQuestions.askTimed({ agent, questions: timedQuestions }, timedCallId, 120_000))
|
||||
.rejects.toThrow(TypeError)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not start the unclaimed hold when the turn ended while the request went unclaimed', async () => {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('timed-preaborted-hold')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
const controller = new AbortController()
|
||||
registerAnswerer(ctx, {
|
||||
ask: () => {
|
||||
controller.abort(new Error('turn ended'))
|
||||
return Promise.reject(wireRejection('NO_PROVIDER'))
|
||||
},
|
||||
})
|
||||
|
||||
await expect(ctx.userQuestions.askTimed(
|
||||
{ agent, questions: timedQuestions, signal: controller.signal }, timedCallId, 5_000,
|
||||
)).rejects.toMatchObject({ name: 'UserQuestionError', code: 'ASK_ABORTED' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects non-positive waits before dispatching', async () => {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('timed-invalid')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
const ask = vi.fn(async () => ({ answers: [] }))
|
||||
registerAnswerer(ctx, { ask })
|
||||
|
||||
await expect(ctx.userQuestions.askTimed({ agent, questions: timedQuestions }, timedCallId, 0))
|
||||
.rejects.toMatchObject({ code: 'BAD_TIMEOUT' })
|
||||
expect(ask).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('late replies', () => {
|
||||
/** The timed tool's schema as the request header records it: the wait parameter is what marks the calls that follow as timed. */
|
||||
const timedToolSchema: ToolSchema = {
|
||||
name: 'ask_user_question',
|
||||
description: 'Ask brief questions.',
|
||||
parameters: { type: 'object', properties: { questions: { type: 'array' }, [TIMED_WAIT_PARAMETER]: { type: 'integer' } } },
|
||||
}
|
||||
|
||||
function askInLog(agent: Agent, callId: ToolCallId, tools: ToolSchema[] = [timedToolSchema]): void {
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
agent.session.append('request/header', {
|
||||
header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, tools },
|
||||
reason: 'initial',
|
||||
})
|
||||
agent.session.append('tool/call', {
|
||||
turn: 1, step: 1, callId, name: 'ask_user_question',
|
||||
arguments: JSON.stringify({ questions: timedQuestions }),
|
||||
})
|
||||
}
|
||||
|
||||
function continueInLog(agent: Agent, callId: ToolCallId): void {
|
||||
agent.session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId, content: [{ type: 'text', text: JSON.stringify({ pending: true, callId }) }], isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
it('steers an answer into a continued question and closes it once the reply enters the inbox', async () => {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('late-answer')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
askInLog(agent, timedCallId)
|
||||
continueInLog(agent, timedCallId)
|
||||
const batch = { answers: [{ id: 'scope', selected: ['Tool only'] }] }
|
||||
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, batch)).toBe(true)
|
||||
|
||||
expect(agent.steer).toHaveBeenCalledOnce()
|
||||
const steered = agent.steer.mock.calls[0]![0]
|
||||
expect(steered.source).toEqual({ kind: 'user-question-reply', callId: timedCallId, outcome: 'answered' })
|
||||
expect(replyText(steered)).toEqual({
|
||||
kind: 'answer_to_pending_question', tool: 'ask_user_question', callId: timedCallId,
|
||||
questions: timedQuestions, answers: batch.answers,
|
||||
})
|
||||
agent.session.append('agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [steered] })
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, batch)).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('answers nothing when the composition carries no Session projections', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const agent = liveAgent('late-no-projections')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
askInLog(agent, timedCallId)
|
||||
continueInLog(agent, timedCallId)
|
||||
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, { answers: [] })).toBe(false)
|
||||
expect(agent.steer).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses an open question and an unknown call', async () => {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('late-open')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
askInLog(agent, timedCallId)
|
||||
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, { answers: [] })).toBe(false)
|
||||
expect(ctx.userQuestions.answer(agent, ToolCallId('never-asked'), { answers: [] })).toBe(false)
|
||||
expect(agent.steer).not.toHaveBeenCalled()
|
||||
expect(agent.inject).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a batch that does not name each question of the call exactly once', async () => {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('late-malformed')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
askInLog(agent, timedCallId)
|
||||
continueInLog(agent, timedCallId)
|
||||
|
||||
for (const answers of [
|
||||
[],
|
||||
[{ id: 'other', selected: ['Tool only'] }],
|
||||
[{ id: 'scope', selected: ['Tool only'] }, { id: 'scope', selected: [] }],
|
||||
[{ id: 'scope', selected: ['Tool only'] }, { id: 'other', selected: [] }],
|
||||
]) {
|
||||
expect(() => ctx.userQuestions.answer(agent, timedCallId, { answers }))
|
||||
.toThrow(expect.objectContaining({ name: 'UserQuestionError', code: 'BAD_ANSWER' }))
|
||||
}
|
||||
expect(agent.steer).not.toHaveBeenCalled()
|
||||
// The question stays continued and takes a well-formed batch afterwards.
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, { answers: [{ id: 'scope', selected: [] }] })).toBe(true)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('never lists a call the blocking legacy tool made, even one the process never finished', async () => {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('legacy-interrupted')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
const legacySchema: ToolSchema = { ...timedToolSchema, parameters: { type: 'object', properties: { questions: { type: 'array' } } } }
|
||||
askInLog(agent, timedCallId, [legacySchema])
|
||||
agent.session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: timedCallId, content: [{ type: 'text', text: 'The tool call was interrupted after it was recorded.' }], isError: true,
|
||||
}),
|
||||
error: { name: 'SessionFormatError', code: TOOL_OUTCOME_UNKNOWN },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
expect(ctx.sessionProjections.stateOf(agent.session, 'userQuestions')?.questions).toEqual({ active: [], settled: [] })
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, { answers: [{ id: 'scope', selected: [] }] })).toBe(false)
|
||||
expect(agent.steer).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -19,21 +19,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -261,7 +261,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-commands` | no | Plugin-owned human command registry for DeepSeek Harness UIs |
|
||||
| `@deepseek-ai/dsh-permission-presets` | yes | User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | yes | Model-facing ask_user_question tool over the ctx.userQuestions seam |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | no | Model-facing ask_user_question tool over the ctx.userQuestions seam |
|
||||
| `@deepseek-ai/dsh-user-approval` | yes | User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default |
|
||||
| `@deepseek-ai/dsh-user-questions` | no | Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs |
|
||||
|
||||
|
||||
Generated
-35
@@ -5807,9 +5807,6 @@ importers:
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/tools
|
||||
'@deepseek-ai/dsh-user-questions':
|
||||
specifier: workspace:*
|
||||
version: link:../../interaction/user-questions
|
||||
'@deepseek-ai/dsh-util-workspace-path':
|
||||
specifier: workspace:*
|
||||
version: link:../../util/workspace-path
|
||||
@@ -5915,9 +5912,6 @@ importers:
|
||||
'@deepseek-ai/dsh-api-session-controller':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/session-controller
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:*
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:*
|
||||
version: link:../connection
|
||||
@@ -5927,9 +5921,6 @@ importers:
|
||||
'@deepseek-ai/dsh-client-store':
|
||||
specifier: workspace:*
|
||||
version: link:../store
|
||||
'@deepseek-ai/dsh-client-ui-chat':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-chat
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-conversation
|
||||
@@ -5945,9 +5936,6 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-client-ui-tool':
|
||||
specifier: workspace:*
|
||||
version: link:../ui-tool
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:*
|
||||
version: link:../../llm/llm
|
||||
@@ -9136,10 +9124,6 @@ importers:
|
||||
version: link:../user-approval
|
||||
|
||||
packages/interaction/tool-ask-user:
|
||||
dependencies:
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
@@ -9192,13 +9176,6 @@ importers:
|
||||
version: link:../../core/system-prompt
|
||||
|
||||
packages/interaction/user-questions:
|
||||
dependencies:
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
@@ -9212,18 +9189,6 @@ importers:
|
||||
'@deepseek-ai/dsh-scope':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/scope
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-persistence-jsonl':
|
||||
specifier: workspace:*
|
||||
version: link:../../session/session-persistence-jsonl
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:*
|
||||
version: link:../../session/session-projection
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../../typert/protocol
|
||||
|
||||
packages/jobs/jobs:
|
||||
devDependencies:
|
||||
|
||||
@@ -180,7 +180,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
configForms: 'client-side shared entry forms — packages/client/ui-settings/README.md owns the API',
|
||||
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-chat/README.md owns the API',
|
||||
shortcuts: 'client-side interface-typed keyboard service — packages/client/shortcuts/README.md owns the API',
|
||||
userQuestionPanels: 'client-side slot-contract accessor (UserQuestionPanels) — packages/client/ui-tool/README.md owns the API',
|
||||
commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API',
|
||||
feedbackUi: 'client-side feedback dialog service — packages/client/ui-message-feedback/README.md owns the API',
|
||||
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API',
|
||||
@@ -417,7 +416,6 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
FileUploadReceiptId: 'attachment.md',
|
||||
FileUploadValue: 'attachment.md',
|
||||
SessionStartSource: 'core.md',
|
||||
ToolCallId: 'core.md',
|
||||
SessionLogSnapshot: 'session-query.md',
|
||||
SessionSurfaceSnapshot: 'session-query.md',
|
||||
ApprovalOutcome: 'approval.md',
|
||||
@@ -731,7 +729,6 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ResolvedCredential: 'credentials.md',
|
||||
AskUserQuestionAnswer: 'user-questions.md',
|
||||
AskUserQuestionRequest: 'user-questions.md',
|
||||
TimedUserQuestionResult: 'user-questions.md',
|
||||
UserQuestionProvider: 'user-questions.md',
|
||||
WebFetchProvider: 'web.md',
|
||||
WebFetchRequest: 'web.md',
|
||||
|
||||
@@ -248,13 +248,13 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
dir: 'tool-ask-user',
|
||||
source: 'packages/interaction/tool-ask-user/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.userQuestions'],
|
||||
writes: ['tool/call', 'tool/result after an answer or timeout', 'late user/message'],
|
||||
writes: ['tool/call', 'tool/result after a UI/provider answers the question'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(UserQuestionService)
|
||||
await ctx.plugin(ToolAskUser)
|
||||
},
|
||||
note:
|
||||
'ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely.',
|
||||
'ask_user_question pauses the tool call until the active UI provider returns a human answer.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tools',
|
||||
|
||||
@@ -502,6 +502,9 @@
|
||||
"packages/client/ui-chat/tests/conversation.client.spec.ts": {
|
||||
"299fcafb8671655372c99c1dbe14e2705536d569bf475ca09693f87b3b1182da": 1
|
||||
},
|
||||
"packages/client/ui-chat/tests/skill-name-projector.client.spec.ts": {
|
||||
"cc5bd784736472e7293f8b0e2d889b0cf1c156a218457571390871164e35b5d5": 1
|
||||
},
|
||||
"packages/client/ui-chat/tests/tool-call-tree.client.spec.ts": {
|
||||
"427f7efc431dec431f6707ea0e4431c54fafc97efbcc9c93865d29f2488300d9": 1
|
||||
},
|
||||
@@ -1338,6 +1341,7 @@
|
||||
"0c7b023d04a2d6744d9844f27de8438231e642fd058e2b75e113a1996711014a": 1
|
||||
},
|
||||
"packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts": {
|
||||
"60655aea47d3d8667f90341b58e3faaf64d12670b9592af837aa40ecf7b83583": 1,
|
||||
"d423bbcd1caff7aab2e2de52564efbc9061e775620a530157805a43d851c6fe9": 1
|
||||
},
|
||||
"packages/interaction/user-approval/tests/approval.spec.ts": {
|
||||
|
||||
@@ -1051,36 +1051,6 @@
|
||||
"symbol": "AskUserQuestionAnswer",
|
||||
"source": "packages/interaction/user-questions/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/user-questions.md",
|
||||
"symbol": "AskUserQuestionRequestEvent",
|
||||
"source": "packages/interaction/user-questions/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/user-questions.md",
|
||||
"symbol": "TimedUserQuestionResult",
|
||||
"source": "packages/interaction/user-questions/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/user-questions.md",
|
||||
"symbol": "UserQuestionState",
|
||||
"source": "packages/interaction/user-questions/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/user-questions.md",
|
||||
"symbol": "PendingUserQuestion",
|
||||
"source": "packages/interaction/user-questions/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/user-questions.md",
|
||||
"symbol": "SettledUserQuestion",
|
||||
"source": "packages/interaction/user-questions/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/user-questions.md",
|
||||
"symbol": "UserQuestionProjectionView",
|
||||
"source": "packages/interaction/user-questions/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/user-questions.md",
|
||||
"symbol": "UserQuestionError",
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user