fix: keep timed question replies recoverable
This commit is contained in:
1 parent
edd0660b5d
commit
fc30c5a7d2
31 files changed
+292
-80
No files matched your search
+6
-6
@@ -21,11 +21,11 @@
|
||||
en: 447713bbdf1ab4d8
|
||||
zh: 2757678ae47822aa
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/durable-state-and-late-answers:
|
||||
en: 7d68057558b56c27
|
||||
zh: 6e26b08aae2422b5
|
||||
en: 93f3def953d10a2e
|
||||
zh: e1106340e0043254
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/card-state-and-delivery-races:
|
||||
en: 40e8c949e0561418
|
||||
zh: 93d2193a40d10ef5
|
||||
en: eaf9ed5a512aa359
|
||||
zh: 33d9af4b22700ffd
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/local-countdown-and-panel-actions:
|
||||
en: 4c0c9ec59fc51217
|
||||
zh: 322870eeb49176c7
|
||||
@@ -39,5 +39,5 @@
|
||||
en: d912a73a5ff409eb
|
||||
zh: 7b626794773e2301
|
||||
/agent-note-timed-user-questions-as-two-settlements/consequences:
|
||||
en: 19f5946e8d7fd2da
|
||||
zh: 2cc1b3e989430430
|
||||
en: 2bc99c99be8ca9da
|
||||
zh: 8f262ec8b9b0945e
|
||||
+6
-5
@@ -48,19 +48,19 @@ Client countdown expiry rejects the waterfall with `ASK_TIMED_OUT`. Unattended H
|
||||
|
||||
The `userQuestions` projection reconstructs question state from existing Session events. For native calls, it recognises the timed schema by the `timeout` parameter recorded in `request/header`; legacy calls are not tracked. A valid `tool/call` opens a question. Its `tool/result` makes it continued for pending or `TOOL_OUTCOME_UNKNOWN`, settles it with a valid answer batch, or removes it for another result or failure. A PTC sub-call enters the projection when its `tool/ptc-dispatch` result is pending, since only `run_code` appears in the model request header.
|
||||
|
||||
A continued question accepts `answer(agent, callId, answer)`. The method returns `false` for an unknown or non-continued call and throws `BAD_ANSWER` unless the batch names each question exactly once. Accepted answers use `agent.steer(createUserMessage(...))`, source `{ kind: 'user-question-reply', callId, outcome: 'answered' }`, and JSON text containing `answer_to_pending_question`, the call id, original questions, and answers. Remote Agent resolution from the Session id resumes a root when needed; the `answer()` body does not own that resume operation.
|
||||
A continued question accepts `answer(agent, callId, answer)`. The method returns `false` for an unknown or non-continued call, throws `REPLY_QUEUED` while another reply awaits admission, and throws `BAD_ANSWER` unless the batch names each question exactly once. Accepted answers use `agent.steer(createUserMessage(...))`, source `{ kind: 'user-question-reply', callId, outcome: 'answered' }`, and JSON text containing `answer_to_pending_question`, the call id, original questions, and answers. Remote Agent resolution from the Session id resumes a root when needed; the `answer()` body does not own that resume operation.
|
||||
|
||||
The inbox message is the durable queued reply. The question settles only when that reply becomes an admitted `user/message`; a queued message can be canceled before admission. Settled answer batches remain in the projection so the original tool row can show late answers even though its own result remains pending. Unusable recorded reply text settles with an empty batch.
|
||||
The inbox message is the durable queued reply. A per-Session reservation covers the interval from queuing through claim and admission; claiming a restored inbox reply creates the reservation again. Admission as `user/message` settles the question, while discard or a Turn that ends without admission releases the reservation for another answer. Settled answer batches remain in the projection so the original tool row can show late answers even though its own result remains pending. Unusable recorded reply text settles with an empty batch.
|
||||
|
||||
No new Session event type or wait-state log is needed. The message-source extension requires the repository's persistence-type acknowledgement. Hiding a panel does not manufacture a reply.
|
||||
|
||||
### Card state and delivery races
|
||||
|
||||
`QuestionCards` shares one `PendingQuestion` between the live request and the continued projection, keyed by Session and `callId`. A blocking request without a call id has its own per-request card with a reload-unique key, so an old persisted draft cannot attach to another request. An open card submits through its waterfall; without that channel it disables submission. A continued card uses the late-answer RPC.
|
||||
`QuestionCards` shares one `PendingQuestion` between the live request and the continued projection, keyed by Session and `callId`. A forwarded request keeps its card through the claim's first frame even before the projection lists it. A blocking request without a call id has its own per-request card with a reload-unique key, so an old persisted draft cannot attach to another request. An open card submits through its waterfall; without that channel it disables submission. A continued card uses the late-answer RPC.
|
||||
|
||||
A waterfall submission has no delivery acknowledgement. The Gateway can discard an outcome that loses a settlement race. The card therefore retains its draft and stays busy until projection reconciliation. If the question becomes continued during submission, the controls re-arm with a resubmit hint; the user can submit the retained draft through RPC. This is not automatic retry or a guarantee that every local submission reaches the model.
|
||||
A waterfall submission has no delivery acknowledgement. The Gateway can discard an outcome that loses a settlement race. The card therefore retains its draft and stays busy until projection reconciliation. If the question becomes continued during submission, the controls re-arm with a resubmit hint; the user can submit the retained draft through RPC. An accepted RPC submission queues a reply but does not settle the question, so the controls re-arm with a queued notice; a discarded reply can be submitted again. This is not automatic retry or a guarantee that every local submission reaches the model.
|
||||
|
||||
A tool-call-keyed card is removed when its call is absent from the active projection and no waterfall remains. Removal closes the card and clears its draft. Neither a local submission nor a transport cancel frame alone decides that the question is answered. An unkeyed blocking card ends with its waterfall; closing that blocking panel rejects with `ASK_CANCELLED`.
|
||||
A tool-call-keyed card is removed when its call is absent from the active projection and no forwarded request remains. Removal closes the card and clears its draft. Neither a local submission nor a transport cancel frame alone decides that the question is answered. An unkeyed blocking card ends with its waterfall; closing that blocking panel rejects with `ASK_CANCELLED`.
|
||||
|
||||
### Local countdown and panel actions
|
||||
|
||||
@@ -114,3 +114,4 @@ The model can continue independent work while the question remains answerable. B
|
||||
- The duplicate projections remain stored. Aggregation adds linear scans during structural grouping rebuilds; ordinary text-only streaming updates retain their incremental path. This is an algorithmic bound, not a measured latency claim.
|
||||
- Unanswered continued questions can remain after reopening a Session. Panel close records nothing, and visibility follows normal folding rather than an always-visible reply guarantee.
|
||||
- A queued late reply can be canceled before the agent admits it. The question stays answerable until an admitted `user/message` records the answer.
|
||||
- A PTC sub-call interrupted before its `tool/ptc-dispatch` result is recorded cannot be reconstructed from the parent `run_code` failure, so it cannot take a late answer after recovery.
|
||||
+6
-5
@@ -48,19 +48,19 @@ Client 倒计时到期以 `ASK_TIMED_OUT` 拒绝 waterfall。无人接手时,H
|
||||
|
||||
`userQuestions` 投影从现有 Session 事件重建问题状态。对原生调用,它通过 `request/header` 记录的 `timeout` 参数识别 timed schema,不跟踪 legacy 调用。有效的 `tool/call` 打开问题。其 `tool/result` 在 pending 或 `TOOL_OUTCOME_UNKNOWN` 时将问题标为已继续,在有效回答批次时结算并保存答案,其他结果或失败则移除问题。PTC 子调用在 `tool/ptc-dispatch` 结果为 pending 时进入投影,因为模型请求头只列出 `run_code`。
|
||||
|
||||
已继续的问题接受 `answer(agent, callId, answer)`。未知或非已继续调用返回 `false`;回答批次没有恰好包含每个问题一次时抛出 `BAD_ANSWER`。接受的回答通过 `agent.steer(createUserMessage(...))` 送入 agent,来源为 `{ kind: 'user-question-reply', callId, outcome: 'answered' }`,JSON 文本包含 `answer_to_pending_question`、调用 id、原问题及答案。Remote 根据 Session id 解析 Agent 时按需恢复根实例;恢复操作不属于 `answer()` 方法体。
|
||||
已继续的问题接受 `answer(agent, callId, answer)`。未知或非已继续调用返回 `false`;另一条回复等待准入时抛出 `REPLY_QUEUED`;回答批次没有恰好包含每个问题一次时抛出 `BAD_ANSWER`。接受的回答通过 `agent.steer(createUserMessage(...))` 送入 agent,来源为 `{ kind: 'user-question-reply', callId, outcome: 'answered' }`,JSON 文本包含 `answer_to_pending_question`、调用 id、原问题及答案。Remote 根据 Session id 解析 Agent 时按需恢复根实例;恢复操作不属于 `answer()` 方法体。
|
||||
|
||||
inbox 消息是持久的排队回复。只有该回复作为 `user/message` 被准入时,问题才结算;排队消息可能在准入前被取消。已结算答案批次保留在投影中,让原工具行即使自身结果仍是 pending,也能显示迟到回答。无法使用的已记录回复文本以空答案批次结算。
|
||||
inbox 消息是持久的排队回复。逐 Session 预约覆盖从排队、领取到准入的期间;领取恢复后的 inbox 回复时会重建预约。回复作为 `user/message` 准入时问题结算;丢弃回复或 Turn 结束而未准入时释放预约,允许再次回答。已结算答案批次保留在投影中,让原工具行即使自身结果仍是 pending,也能显示迟到回答。无法使用的已记录回复文本以空答案批次结算。
|
||||
|
||||
无需新增 Session 事件类型或等待状态日志。消息来源扩展需要仓库的持久化类型变更确认。隐藏面板不会伪造回复。
|
||||
|
||||
### 卡片状态与投递竞争
|
||||
|
||||
`QuestionCards` 按 Session 和 `callId`,让实时请求与已继续投影共享一个 `PendingQuestion`。没有调用 id 的阻塞请求拥有独立的逐请求卡片,并使用跨重载唯一的键,避免旧草稿附着到另一请求。开放卡片通过 waterfall 提交;缺少该通道时禁用提交。已继续卡片使用迟到回答 RPC。
|
||||
`QuestionCards` 按 Session 和 `callId`,让实时请求与已继续投影共享一个 `PendingQuestion`。转发请求在投影列出问题之前,仍会让卡片存活至接手 stream 的首帧。没有调用 id 的阻塞请求拥有独立的逐请求卡片,并使用跨重载唯一的键,避免旧草稿附着到另一请求。开放卡片通过 waterfall 提交;缺少该通道时禁用提交。已继续卡片使用迟到回答 RPC。
|
||||
|
||||
waterfall 提交没有送达确认。Gateway 可能丢弃输掉结算竞争的结果,因此卡片保留草稿并保持忙碌,等待投影同步。若提交期间问题变为已继续,控件带重提提示重新可用,用户可以通过 RPC 提交保留的草稿。这不是自动重试,也不保证每次本地提交都能到达模型。
|
||||
waterfall 提交没有送达确认。Gateway 可能丢弃输掉结算竞争的结果,因此卡片保留草稿并保持忙碌,等待投影同步。若提交期间问题变为已继续,控件带重提提示重新可用,用户可以通过 RPC 提交保留的草稿。RPC 接受提交只会将回复排队,不会结算问题,所以控件带排队提示重新可用;回复被丢弃后可以再次提交。这不是自动重试,也不保证每次本地提交都能到达模型。
|
||||
|
||||
按工具调用标识的卡片在调用不属于活动投影且没有 waterfall 时移除。移除会关闭卡片并清理草稿。本地提交和传输取消帧都不能单独决定问题已经回答。无调用标识的阻塞卡片随 waterfall 结束;关闭这种阻塞面板以 `ASK_CANCELLED` 拒绝请求。
|
||||
按工具调用标识的卡片在调用不属于活动投影且没有转发中的请求时移除。移除会关闭卡片并清理草稿。本地提交和传输取消帧都不能单独决定问题已经回答。无调用标识的阻塞卡片随 waterfall 结束;关闭这种阻塞面板以 `ASK_CANCELLED` 拒绝请求。
|
||||
|
||||
### 本地倒计时与面板操作
|
||||
|
||||
@@ -114,3 +114,4 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
- 重复投影仍保留在存储中。聚合在结构性分组重建时增加线性扫描;普通纯文本 stream 更新保留增量路径。这是算法复杂度界限,不是实测延迟结论。
|
||||
- 无人回答的已继续问题可以在重新打开 Session 后继续存在。关闭面板不写记录,可见性遵循普通折叠,而不是保证回复始终可见。
|
||||
- 排队的迟到回复可能在 agent 准入前被取消。只有已准入的 `user/message` 记录答案,问题在此之前保持可回答。
|
||||
- PTC 子调用若在记录 `tool/ptc-dispatch` 结果前中断,无法从父 `run_code` 的失败中重建,因此恢复后不能接受迟到回答。
|
||||
@@ -21,8 +21,8 @@
|
||||
en: efd2dbb95a885d0e
|
||||
zh: 7a20c75c4ea6a9e0
|
||||
/user-interaction/timed-questions-and-late-replies:
|
||||
en: 770cf41197c8cd12
|
||||
zh: 9276ce295b4a543e
|
||||
en: 74fa698d4a115e80
|
||||
zh: 4e9b03b5eab2ae5e
|
||||
/user-interaction/errors:
|
||||
en: 5762b1498a426ab6
|
||||
zh: bb9987ec82103620
|
||||
@@ -184,7 +184,7 @@ interface UserQuestionProjectionView {
|
||||
}
|
||||
```
|
||||
|
||||
An open question accepts answers only through the waterfall. Once it is `continued`, the `answer` Remote method queues a user message for the owning agent and wakes it if idle. The message has source `user-question-reply`, outcome `answered`, and an `answer_to_pending_question` payload. The method refuses open questions and a second reply while one is queued, including across a Host restart. It rejects incomplete or duplicate answer batches with `BAD_ANSWER`.
|
||||
An open question accepts answers only through the waterfall. Once it is `continued`, the `answer` Remote method queues a user message for the owning agent and wakes it if idle. The message has source `user-question-reply`, outcome `answered`, and an `answer_to_pending_question` payload. The method returns `false` for an open or unknown call, rejects a second queued reply with `REPLY_QUEUED` even after a Host restart, and rejects incomplete or duplicate answer batches with `BAD_ANSWER`.
|
||||
|
||||
Closing the Client panel sends nothing. A foreground timeout ends only the tool wait. Continued questions have no deadline; they stay answerable until a reply is admitted.
|
||||
|
||||
@@ -226,9 +226,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
* @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 had no reply already queued.
|
||||
* @returns Whether the question is still continued; an accepted reply stays
|
||||
* queued until the agent admits its user message.
|
||||
* @throws {UserQuestionError} `BAD_ANSWER` when the batch does not name each
|
||||
* question of the call exactly once.
|
||||
* question of the call exactly once, or `REPLY_QUEUED` when a reply is
|
||||
* already waiting for admission.
|
||||
*/
|
||||
@Remote answer(agent: Agent, callId: ToolCallId, answer: AskUserQuestionAnswer): boolean
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ interface UserQuestionProjectionView {
|
||||
}
|
||||
```
|
||||
|
||||
开放中的问题只能通过 waterfall 回答。问题变为 `continued` 后,`answer` Remote 方法向所属 agent 排入一条用户消息,并在其空闲时唤醒它。消息的 source 为 `user-question-reply`,outcome 为 `answered`,正文是 `answer_to_pending_question` 载荷。该方法拒绝开放中的问题,以及回复仍在排队时的第二次提交,Host 重启后也一样;遗漏或重复问题的回答批次以 `BAD_ANSWER` 拒绝。
|
||||
开放中的问题只能通过 waterfall 回答。问题变为 `continued` 后,`answer` Remote 方法向所属 agent 排入一条用户消息,并在其空闲时唤醒它。消息的 source 为 `user-question-reply`,outcome 为 `answered`,正文是 `answer_to_pending_question` 载荷。开放或未知调用返回 `false`;回复仍在排队时的第二次提交以 `REPLY_QUEUED` 拒绝,Host 重启后也一样;遗漏或重复问题的回答批次以 `BAD_ANSWER` 拒绝。
|
||||
|
||||
收起 Client 面板不会发送内容。前台超时只结束工具等待。已继续的问题没有期限;在回复进入会话前,问题仍可回答。
|
||||
|
||||
@@ -226,9 +226,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
* @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 had no reply already queued.
|
||||
* @returns Whether the question is still continued; an accepted reply stays
|
||||
* queued until the agent admits its user message.
|
||||
* @throws {UserQuestionError} `BAD_ANSWER` when the batch does not name each
|
||||
* question of the call exactly once.
|
||||
* question of the call exactly once, or `REPLY_QUEUED` when a reply is
|
||||
* already waiting for admission.
|
||||
*/
|
||||
@Remote answer(agent: Agent, callId: ToolCallId, answer: AskUserQuestionAnswer): boolean
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ export function QuestionToolRow({
|
||||
const open = expanded && expandable
|
||||
return (
|
||||
<div className={css.root} data-variant="others" data-tool="ask_user_question" data-state={state}>
|
||||
{(state === 'running' || state === 'error' || state === 'stopped') &&
|
||||
<span className={css.visuallyHidden}>{t(state === 'running' ? 'row.running' : state === 'error' ? 'row.failed' : 'row.stopped')}</span>}
|
||||
{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')}
|
||||
|
||||
@@ -261,6 +261,9 @@ export function AskQuestionRow({
|
||||
summary = t('ask.pending')
|
||||
if (questions !== null) transcript = { kind: 'unanswered', questions, verdict: t('ask.pendingDetail') }
|
||||
rowAction = reopen
|
||||
} else if (settled?.answers.length === 0) {
|
||||
summary = t('ask.closed')
|
||||
if (questions !== null) transcript = { kind: 'unanswered', questions, verdict: t('ask.closedDetail') }
|
||||
} else if (settled !== undefined) {
|
||||
const presentation = answeredPresentation(questions, settled.answers, t)
|
||||
summary = presentation.summary
|
||||
|
||||
@@ -223,6 +223,18 @@ describe('AskQuestionRow', () => {
|
||||
expect(reviewPanel).toHaveBeenCalledWith('c1', expect.objectContaining({ answers: batch }))
|
||||
})
|
||||
|
||||
it('shows an unreadable late reply after crash repair as closed', () => {
|
||||
const reviewPanel = vi.fn(() => true)
|
||||
const repaired = resultNode(READABLE_ARGS, 'The tool call was interrupted after it was recorded.', {
|
||||
isError: true, error: { name: 'SessionFormatError', code: 'TOOL_OUTCOME_UNKNOWN' },
|
||||
})
|
||||
render(<AskQuestionRow {...rowProps(repaired, { settled: { c1: [] }, reviewPanel })} />)
|
||||
|
||||
expect(screen.getByText('已结束')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: PILL.review })).toBeNull()
|
||||
expect(reviewPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 6b12f1362c614a39
|
||||
zh: 27d9cf54d4cbd5d2
|
||||
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/failure-and-recovery:
|
||||
en: 1dc5c51143c6634e
|
||||
zh: 855fba041fb868b5
|
||||
en: 2440f61b0b3f0088
|
||||
zh: ea357b5bbee1834c
|
||||
/deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation:
|
||||
en: 77e1b2585acdf67b
|
||||
zh: d433ec185c6b8f57
|
||||
|
||||
@@ -47,11 +47,11 @@ An answer field autofocuses only when its answer channel is ready and no countdo
|
||||
|
||||
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.
|
||||
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 card remains live while that first frame is pending. The claim lasts until the 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, for tool-call-keyed requests, 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.
|
||||
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. An accepted Remote answer is queued, not yet admitted; the card shows a queued notice and unlocks its controls so a discarded reply can be submitted again. A second answer while the first remains queued reports `REPLY_QUEUED`. 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, for tool-call-keyed requests, 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. A tool-call-keyed request restores unfinished input after Session navigation, page reload, or browser restart. An unnamed legacy request has a unique card key and cannot be restored after reload. A hidden panel keeps its draft for the next opening; a mounted closed card clears its own draft, and the next mounted card prunes drafts no live card owns. Drafts do not synchronize to another browser or device; the Host-projected question does.
|
||||
Each Session and pending request has an independent draft in browser storage. A tool-call-keyed request restores unfinished input after Session navigation, page reload, or browser restart; progress that no longer matches the question batch is ignored. An unnamed legacy request has a unique card key and cannot be restored after reload. A hidden panel keeps its draft for the next opening; a mounted closed card clears its own draft, and the next mounted card prunes drafts no live 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.
|
||||
|
||||
|
||||
@@ -47,11 +47,11 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户
|
||||
|
||||
本地返回回答不会释放前台接手记录:Host 接受 waterfall 结果后才关闭该 stream。插件在这段传输期间继续拥有接手记录。显式委托会在调用下一个回答方前释放它,插件卸载则取消剩余接手记录。
|
||||
|
||||
卡片有两个来源。Host 的存活请求创建它,回答也经该请求返回。计时请求先由 Client 打开问答业务的 `attachWait` stream,再从剩余时长帧推导本地时钟上的 deadline。接手记录持续到请求结算或 Client 断开,收起面板不会释放它。倒计时归卡片所有;到零时以 `ASK_TIMED_OUT` reject,卡片仍可用。投影把调用列为已继续后,回答改用 `answer` Remote 方法。卡片移除遵循投影,插件卸载时释放请求与接手记录。
|
||||
卡片有两个来源。Host 的存活请求创建它,回答也经该请求返回。计时请求先由 Client 打开问答业务的 `attachWait` stream,再从剩余时长帧推导本地时钟上的 deadline。等待首帧期间卡片仍保持存活。接手记录持续到请求结算或 Client 断开,收起面板不会释放它。倒计时归卡片所有;到零时以 `ASK_TIMED_OUT` reject,卡片仍可用。投影把调用列为已继续后,回答改用 `answer` Remote 方法。卡片移除遵循投影,插件卸载时释放请求与接手记录。
|
||||
|
||||
经挂起请求的提交只算发出,不算确认:另一个浏览器先结算了同一请求时,gateway 会静默丢弃迟到的结果。因此卡片保留草稿直到投影关闭它;若提交在途时该调用变为已继续,控件带提示重新可用,同一份草稿改走 Remote 路径。未编辑时聚焦会冻结本浏览器的剩余倒计时,失焦后按该余量恢复。首次修改回答会把本地等待改为无限,「慢慢回答」也会显式执行同样操作。这些选择会随本浏览器草稿跨会话导航保留;对于带工具调用 ID 的请求,重新加载后也会保留。另一浏览器拥有自己的倒计时,仍可结算共享请求;Host projection 继续作为问题处于开放或已继续状态的权威来源。
|
||||
经挂起请求的提交只算发出,不算确认:另一个浏览器先结算了同一请求时,gateway 会静默丢弃迟到的结果。因此卡片保留草稿直到投影关闭它;若提交在途时该调用变为已继续,控件带提示重新可用,同一份草稿改走 Remote 路径。Remote 接受回答只表示回复已排队、尚未准入;卡片显示排队提示并解锁控件,让被丢弃的回复可以再次提交。第一条回复仍在排队时再次提交会收到 `REPLY_QUEUED`。未编辑时聚焦会冻结本浏览器的剩余倒计时,失焦后按该余量恢复。首次修改回答会把本地等待改为无限,「慢慢回答」也会显式执行同样操作。这些选择会随本浏览器草稿跨会话导航保留;对于带工具调用 ID 的请求,重新加载后也会保留。另一浏览器拥有自己的倒计时,仍可结算共享请求;Host projection 继续作为问题处于开放或已继续状态的权威来源。
|
||||
|
||||
每个会话和待处理请求在浏览器存储中都有独立草稿。带工具调用 ID 的请求在切换会话、重新加载页面或重启同一个浏览器后会恢复未完成输入。没有 ID 的 legacy 请求使用唯一卡片键,重新加载后不能恢复。隐藏的面板会保留草稿供下次打开;已关闭但仍挂载的卡片会清掉自己的草稿,下一张挂载的卡片会修剪没有存活卡片拥有的草稿。草稿不会同步到另一个浏览器或设备,Host 投影的问题会。
|
||||
每个会话和待处理请求在浏览器存储中都有独立草稿。带工具调用 ID 的请求在切换会话、重新加载页面或重启同一个浏览器后会恢复未完成输入;与当前问题批次不符的进度会被忽略。没有 ID 的 legacy 请求使用唯一卡片键,重新加载后不能恢复。隐藏的面板会保留草稿供下次打开;已关闭但仍挂载的卡片会清掉自己的草稿,下一张挂载的卡片会修剪没有存活卡片拥有的草稿。草稿不会同步到另一个浏览器或设备,Host 投影的问题会。
|
||||
|
||||
工具调用开放期间重新连接的浏览器会再次收到挂起请求,仍可用剩余时间完成它。在 agent 或进程结束后重新打开的会话把卡片显示为已继续;提交时先恢复会话的根 agent,再把回答作为新的用户轮次送入。
|
||||
|
||||
|
||||
@@ -31,16 +31,22 @@ export function PlanReviewPanel({ pending, review, t, renderSlot }: PlanReviewPa
|
||||
// clicks must not resubmit. A failed send re-enables it and shows the error.
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const settle = (send: () => Promise<void>): void => {
|
||||
const settle = (send: () => Promise<void>, queued = false): void => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
void send().catch((cause: unknown) => {
|
||||
setBusy(false)
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
})
|
||||
void send()
|
||||
.then(() => {
|
||||
if (!queued) return
|
||||
setBusy(false)
|
||||
setError(t('status.queued'))
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
setBusy(false)
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
})
|
||||
}
|
||||
const decide = (label: string): void => {
|
||||
settle(() => pending.answer({ answers: [{ id: review.id, selected: [label] }] }))
|
||||
settle(() => pending.answer({ answers: [{ id: review.id, selected: [label] }] }), pending.snapshot().channel === 'rpc')
|
||||
}
|
||||
const summary = useMemo(() => {
|
||||
const title = extractMarkdownPlainText(review.plan, { mode: 'first-line' })
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, 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,7 +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 }
|
||||
type Feedback = { key: 'error.incomplete' | 'error.unanswered' | 'error.unavailable' | 'error.resubmit' | 'status.queued' } | { text: string }
|
||||
|
||||
/** A removed card can remain mounted until the composer seat updates. */
|
||||
const REMOVED_CARD: QuestionCardSnapshot = {
|
||||
@@ -39,6 +39,22 @@ export function parseRecommendedLabel(label: string): { label: string; recommend
|
||||
: { label, recommended: false }
|
||||
}
|
||||
|
||||
/** Accept persisted progress only when it still describes this question batch. */
|
||||
function isQuestionDraftProgress(value: unknown, questionCount: number): value is QuestionDraftProgress {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const progress = value as Record<string, unknown>
|
||||
if (typeof progress.index !== 'number' || !Number.isInteger(progress.index)
|
||||
|| progress.index < 0 || progress.index >= questionCount
|
||||
|| !Array.isArray(progress.drafts) || progress.drafts.length !== questionCount
|
||||
|| (progress.wait !== undefined && progress.wait !== 'editing' && progress.wait !== 'waiting')) return false
|
||||
return progress.drafts.every((item: unknown) => {
|
||||
if (typeof item !== 'object' || item === null || Array.isArray(item)) return false
|
||||
const draft = item as Record<string, unknown>
|
||||
return Array.isArray(draft.selected) && draft.selected.every((label: unknown) => typeof label === 'string')
|
||||
&& typeof draft.custom === 'string' && typeof draft.skipped === 'boolean'
|
||||
})
|
||||
}
|
||||
|
||||
/** Return whether a text-field key event belongs to an active IME composition. */
|
||||
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
@@ -158,10 +174,11 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
})
|
||||
}, [questions, review])
|
||||
const stored = useStore(state => state.progressByRequest[pending.key])
|
||||
const validStored = isQuestionDraftProgress(stored, questions.length) ? stored : undefined
|
||||
// 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 storedProgress = review === undefined ? validStored : undefined
|
||||
const index = validStored?.index ?? 0
|
||||
const drafts = storedProgress?.drafts ?? initialDrafts
|
||||
const restoredWait = storedProgress?.wait
|
||||
?? (storedProgress?.drafts.some(item => item.selected.length > 0 || item.custom !== '' || item.skipped) === true
|
||||
@@ -178,7 +195,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
// 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)
|
||||
const sentVia = useRef<'waterfall' | null>(null)
|
||||
useEffect(() => {
|
||||
if (sentVia.current !== 'waterfall' || card.state !== 'continued') return
|
||||
sentVia.current = null
|
||||
@@ -338,8 +355,17 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi
|
||||
}
|
||||
setBusy('answer')
|
||||
setError(null)
|
||||
sentVia.current = card.channel === 'waterfall' ? 'waterfall' : 'rpc'
|
||||
// The external-store render can lag the carrier as a timed call continues.
|
||||
// Read the channel used by answer() in this same event turn.
|
||||
const channel = pending.snapshot().channel
|
||||
sentVia.current = channel === 'waterfall' ? 'waterfall' : null
|
||||
void pending.answer(answer)
|
||||
.then(() => {
|
||||
if (channel !== 'rpc') return
|
||||
sentVia.current = null
|
||||
setBusy(null)
|
||||
setError({ key: 'status.queued' })
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
sentVia.current = null
|
||||
setBusy(null)
|
||||
|
||||
@@ -137,7 +137,7 @@ export interface QuestionWaterfallChannel {
|
||||
|
||||
/** 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. */
|
||||
/** Steer the answer into the agent; false when the question is no longer continued. Rejects when a reply is already queued. */
|
||||
answer(answer: QuestionAnswer): Promise<boolean>
|
||||
}
|
||||
|
||||
|
||||
@@ -63,12 +63,10 @@ interface QuestionCard {
|
||||
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
|
||||
/** Track forwarded requests through claim opening and waterfall settlement. */
|
||||
readonly trackRequest: (completion: Promise<void>) => () => void
|
||||
/** Whether the forwarded request is still opening or waiting for settlement. */
|
||||
readonly hasRequest: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,10 +115,10 @@ class QuestionCards {
|
||||
|
||||
/** Publish one carrier into the composer seat and register the card that owns it. */
|
||||
#create(pending: PendingQuestion): QuestionCard {
|
||||
let listener: Promise<void> | undefined
|
||||
const requests = new Set<Promise<void>>()
|
||||
const delegate = async (): Promise<void> => {
|
||||
pending.delegate()
|
||||
await listener
|
||||
await Promise.all(requests)
|
||||
}
|
||||
// Republishing is how a revealed card reaches the seat: the registry keeps
|
||||
// the last entry of equal precedence, and it rejects a duplicate key.
|
||||
@@ -143,7 +141,11 @@ class QuestionCards {
|
||||
unpublish?.()
|
||||
unpublish = undefined
|
||||
},
|
||||
awaitListener: (completion) => { listener = completion },
|
||||
trackRequest: (completion) => {
|
||||
requests.add(completion)
|
||||
return () => { requests.delete(completion) }
|
||||
},
|
||||
hasRequest: () => requests.size > 0,
|
||||
}
|
||||
// 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.
|
||||
@@ -231,7 +233,7 @@ async function answerQuestion(
|
||||
}, 'ui-user-questions: foreground claim')
|
||||
: undefined
|
||||
const completed = Promise.withResolvers<void>()
|
||||
card.awaitListener(completed.promise)
|
||||
const finishRequest = card.trackRequest(completed.promise)
|
||||
try {
|
||||
const iterator = claim?.[Symbol.asyncIterator]()
|
||||
const opening = iterator === undefined ? undefined : await iterator.next()
|
||||
@@ -272,6 +274,7 @@ async function answerQuestion(
|
||||
// A blocking request without a call id is not in the projection; its card ends with its waterfall.
|
||||
if (callId === undefined) card.remove()
|
||||
completed.resolve()
|
||||
finishRequest()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +332,7 @@ function publishContinuedQuestions(ctx: ClientContext, cards: QuestionCards): ()
|
||||
if (card.pending.callId === undefined
|
||||
|| card.pending.review !== undefined
|
||||
|| rows.has(card.pending.key)
|
||||
|| card.hasRequest()
|
||||
|| card.pending.hasWaterfall()) continue
|
||||
card.remove()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const zh = {
|
||||
'error.unanswered': '请选择一个选项或填写自定义答案。',
|
||||
'error.unavailable': '当前无法提交,请稍候再试。',
|
||||
'error.resubmit': '回答未送达,工作已继续,请再提交一次。',
|
||||
'status.queued': '回答已排队等待处理;如未送达,可以再次提交。',
|
||||
'wait.takeTime': '慢慢回答',
|
||||
'wait.countdown': '{seconds} 秒后继续工作',
|
||||
'wait.paused': '已暂停 · 剩余 {seconds} 秒',
|
||||
@@ -43,6 +44,7 @@ export const en = {
|
||||
'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.',
|
||||
'status.queued': 'Reply queued for delivery. You can submit again if it does not arrive.',
|
||||
'wait.takeTime': 'Take time',
|
||||
'wait.countdown': 'Continuing in {seconds}s',
|
||||
'wait.paused': 'Paused · {seconds}s remaining',
|
||||
|
||||
@@ -216,6 +216,35 @@ describe('apply', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a timed card during a Session update before the claim first frame', async () => {
|
||||
const b = await bench()
|
||||
const opening = Promise.withResolvers<IteratorResult<{ remainingMs: number }>>()
|
||||
const ended = Promise.withResolvers<IteratorResult<{ remainingMs: number }>>()
|
||||
b.remoteQuestions.attachWait.mockImplementationOnce(() => {
|
||||
let reads = 0
|
||||
return {
|
||||
dispose: () => { ended.resolve({ done: true, value: undefined }) },
|
||||
send: () => {}, end: () => {},
|
||||
[Symbol.asyncIterator]: () => ({ next: () => ++reads === 1 ? opening.promise : ended.promise }),
|
||||
}
|
||||
})
|
||||
try {
|
||||
const result = b.invoke(b.agent, timed(), async () => ANSWER)
|
||||
const card = b.pending.getSnapshot()[0]!
|
||||
b.list.set({ ...b.list.getSnapshot() })
|
||||
expect(b.pending.getSnapshot()).toContain(card)
|
||||
expect(card.snapshot().closed).toBe(false)
|
||||
|
||||
opening.resolve({ done: false, value: { remainingMs: 60_000 } })
|
||||
await vi.waitFor(() => { expect(card.snapshot().channel).toBe('waterfall') })
|
||||
await card.answer(ANSWER)
|
||||
await expect(result).resolves.toEqual(ANSWER)
|
||||
} finally {
|
||||
opening.resolve({ done: true, value: undefined })
|
||||
await b.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches an indefinite request before publishing control back to the caller', async () => {
|
||||
const b = await bench()
|
||||
try {
|
||||
@@ -580,6 +609,25 @@ describe('apply', () => {
|
||||
await expect(card.answer(ANSWER)).rejects.toThrow('offline')
|
||||
})
|
||||
|
||||
it('queues an older answer while a later question is waiting', async () => {
|
||||
const currentCall = ToolCallId('call-current')
|
||||
const b = await bench(true, [CONTINUED, { ...CONTINUED, callId: currentCall, state: 'open' }])
|
||||
const current = b.invoke(b.agent, { ...timed(), wait: { callId: currentCall, timed: true } }, async () => ANSWER)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.pending.getSnapshot().find(card => card.callId === currentCall)?.snapshot().channel).toBe('waterfall')
|
||||
})
|
||||
|
||||
const currentCard = b.pending.getSnapshot().find(card => card.callId === currentCall)!
|
||||
const earlier = b.pending.getSnapshot().find(card => card.callId === CALL)!
|
||||
expect(earlier.snapshot()).toMatchObject({ state: 'continued', channel: 'rpc' })
|
||||
await earlier.answer(ANSWER)
|
||||
expect(b.remoteQuestions.answer).toHaveBeenCalledWith(SESSION_ID, CALL, ANSWER)
|
||||
expect(currentCard.snapshot().channel).toBe('waterfall')
|
||||
|
||||
currentCard.timeout()
|
||||
await expect(current).rejects.toMatchObject({ code: 'ASK_TIMED_OUT' })
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -295,6 +295,20 @@ describe('PlanReviewPanel', () => {
|
||||
expect(answer).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-arms a continued plan decision after the reply is queued', async () => {
|
||||
const carrier = new PendingQuestion(SID, questions(), 'continued-plan' as ToolCallId)
|
||||
const answer = vi.fn(async () => true)
|
||||
carrier.attachRpc({ answer })
|
||||
carrier.setState('continued')
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
|
||||
expect(await screen.findByText(zh['status.queued'])).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
|
||||
expect(answer).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
|
||||
it('dismisses the request so the composer returns for a plain message', () => {
|
||||
const { carrier, cancel, answer } = wait()
|
||||
|
||||
@@ -467,6 +467,16 @@ describe('PendingQuestion domain face', () => {
|
||||
expect(screen.queryByText('stale')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores persisted progress that no longer matches the question batch', () => {
|
||||
const carrier = new PendingQuestion(SID, QUESTIONS, ToolCallId('stale-progress'))
|
||||
draftInstance.actions.replace(carrier.key, { index: 7, drafts: [] })
|
||||
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: QUESTIONS[0]!.question })).toBeTruthy()
|
||||
expect(screen.getByText(`1 / ${QUESTIONS.length}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('collapses the card to the header strip and expands it back', () => {
|
||||
const { carrier } = wait()
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
@@ -686,6 +696,45 @@ describe('timed card', () => {
|
||||
await vi.waitFor(() => { expect(answer).toHaveBeenCalledWith({ answers: [{ id: 'scope', selected: ['仅工具'] }] }) })
|
||||
})
|
||||
|
||||
it('reports an already queued RPC reply and allows retry after it is discarded', async () => {
|
||||
const carrier = new PendingQuestion(SID, TIMED, ToolCallId('ask-retry'))
|
||||
const answer = vi.fn(async () => true)
|
||||
answer.mockResolvedValueOnce(true).mockRejectedValueOnce(new Error('a reply is already queued for this question'))
|
||||
carrier.attachRpc({ answer })
|
||||
carrier.setState('continued')
|
||||
render(<QuestionComposer matched={carrier} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: '仅工具' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
await vi.waitFor(() => { expect(screen.getByText('回答已排队等待处理;如未送达,可以再次提交。')).toBeTruthy() })
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
expect(await screen.findByText('a reply is already queued for this question')).toBeTruthy()
|
||||
expect(answer).toHaveBeenCalledTimes(2)
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
await vi.waitFor(() => { expect(answer).toHaveBeenCalledTimes(3) })
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('unlocks an older reply when the rendered channel lags its continued state', async () => {
|
||||
const carrier = new PendingQuestion(SID, TIMED, ToolCallId('ask-stale-channel'))
|
||||
carrier.attachRpc({ answer: vi.fn(async () => true) })
|
||||
carrier.setState('continued')
|
||||
const rendered = { ...carrier.snapshot(), state: 'open' as const, channel: 'waterfall' as const }
|
||||
const useQuestionCard = ((_key: string, selector?: (value: QuestionCardSnapshot | undefined) => unknown) =>
|
||||
selector === undefined ? rendered : selector(rendered)) as QuestionComposerProps['useQuestionCard']
|
||||
render(<Composer matched={carrier} {...kit} useQuestionCard={useQuestionCard} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: '仅工具' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
|
||||
expect(await screen.findByText(zh['status.queued'])).toBeTruthy()
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -3406,8 +3406,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
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 had no reply already queued.',
|
||||
throws: ['{UserQuestionError} `BAD_ANSWER` when the batch does not name each question of the call exactly once.'],
|
||||
returns: 'Whether the question is still continued; an accepted reply stays queued until the agent admits its user message.',
|
||||
throws: ['{UserQuestionError} `BAD_ANSWER` when the batch does not name each question of the call exactly once, or `REPLY_QUEUED` when a reply is already waiting for admission.'],
|
||||
},
|
||||
{
|
||||
signature: '@Remote({ mode: \'stream\' }) async *attachWait(agent: Agent, callId: ToolCallId, signal: AbortSignal): AsyncIterable<{ remainingMs: number }>',
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
en: f5d5f97a153f7c70
|
||||
zh: 4db81881ebdcfcc4
|
||||
/deepseek-ai-dsh-tool-ask-user/known-limitations-and-deferred-work:
|
||||
en: cdc4541fb6617f73
|
||||
zh: 1b162761f2a55d42
|
||||
en: c8df1368c2959465
|
||||
zh: b14fb07a60e75ebf
|
||||
/deepseek-ai-dsh-tool-ask-user/known-limitations-and-deferred-work/dev-note:
|
||||
en: 0b06d843322610c1
|
||||
zh: a1e7cadd4f03c384
|
||||
@@ -154,6 +154,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** — it returns an answer or an error. Native legacy calls do not enter the `userQuestions` projection, and interrupted calls cannot take late answers.
|
||||
- **An interrupted PTC wait may lose its question** — if the `run_code` process ends before an `ask_user_question` sub-call records its `tool/ptc-dispatch` result, the projection cannot reconstruct that sub-call for a late answer.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ assistant 工具调用保留问题。等待期间收到的回答会在下一步
|
||||
这些限制说明该工具何时不合适。它们是当前包约束,不是 UI 积压事项。
|
||||
|
||||
- **Legacy 工具不会报告 pending**:它只返回回答或错误。原生 legacy 调用不进入 `userQuestions` 投影;中断的调用不能接受迟到回答。
|
||||
- **中断的 PTC 等待可能丢失问题**:若 `run_code` 进程在 `ask_user_question` 子调用记录 `tool/ptc-dispatch` 结果前结束,投影无法重建该子调用以接受迟到回答。
|
||||
- **运行时中归属于其他 agent 的 subagent 不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝归属于另一个 agent 的存活子级;该子级必须在最终结果中包含尚未解决的问题或决定。持久谱系不能决定这一边界,因此带有谱系的会话恢复为运行时根后可以正常提问。
|
||||
- **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export function registerTimedAskUser(ctx: Context, timeout = 120): void {
|
||||
// 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.`,
|
||||
description: `Wait seconds for the entire batch (default ${defaultTimeout}); omit unless the user specifies a duration. Use -1 only when an answer is required before proceeding.`,
|
||||
},
|
||||
},
|
||||
output: {
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('ask_user_question tool', () => {
|
||||
|
||||
expect(schema?.parameters).toHaveProperty('properties.timeout')
|
||||
expect(JSON.stringify(schema)).toContain('pending')
|
||||
expect(JSON.stringify(schema)).toContain('Wait seconds (default 120)')
|
||||
expect(JSON.stringify(schema)).toContain('Wait seconds for the entire batch (default 120)')
|
||||
})
|
||||
|
||||
it('falls back to the 120 second wait when apply receives a row without a timeout', async () => {
|
||||
@@ -130,7 +130,7 @@ describe('ask_user_question tool', () => {
|
||||
|
||||
toolAskUser.apply(ctx, { mode: 'timed' })
|
||||
|
||||
expect(JSON.stringify(ctx.tools.schemas().find(tool => tool.name === 'ask_user_question'))).toContain('Wait seconds (default 120)')
|
||||
expect(JSON.stringify(ctx.tools.schemas().find(tool => tool.name === 'ask_user_question'))).toContain('Wait seconds for the entire batch (default 120)')
|
||||
})
|
||||
|
||||
it('returns the answer batch when the Client answers inside the configured wait', async () => {
|
||||
@@ -163,8 +163,8 @@ describe('ask_user_question tool', () => {
|
||||
|
||||
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)')
|
||||
expect(JSON.stringify(schema)).toContain('Wait seconds for the entire batch (default 45); omit unless the user specifies a duration. Use -1 only when an answer is required before proceeding.')
|
||||
expect(JSON.stringify(schema)).not.toContain('Wait seconds for the entire batch (default 120)')
|
||||
})
|
||||
|
||||
it('registers a model-facing tool schema', async () => {
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 0c45d3161b0df13a
|
||||
zh: efff84a7917a799e
|
||||
/deepseek-ai-dsh-user-questions/role:
|
||||
en: 5181734cde2f7f14
|
||||
zh: 6ad9ac1fe5c8227c
|
||||
en: 1a321b5a204dc4dd
|
||||
zh: 5e30055a3774e606
|
||||
/deepseek-ai-dsh-user-questions/model-experience:
|
||||
en: 95167d0c957a0ab5
|
||||
zh: 582c04395648786c
|
||||
|
||||
@@ -37,7 +37,7 @@ While the tool call is open, the only answer path is that request; a browser tha
|
||||
<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: native calls use the logged tool schema, while PTC sub-calls enter when their result is pending. The `answer` RPC validates one answer per question and accepts one queued reply per continued call. A discarded reply leaves the question answerable. A late batch stays in the projection because the original tool result contains the timeout, not that answer.
|
||||
`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: native calls use the logged tool schema, while PTC sub-calls enter when their result is pending. The `answer` RPC validates one answer per question and accepts one queued reply per continued call; a second reply fails with `REPLY_QUEUED`. A discarded reply leaves the question answerable. A late batch stays in the projection because the original tool result contains the timeout, not that answer.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
@@ -37,7 +37,7 @@ kind: "package-reference"
|
||||
<a id="role"></a>
|
||||
## 职责
|
||||
|
||||
`UserQuestionService` 拥有每个 `TimedQuestionWait`、可取消的 Client 接手记录和无人接手计时器。有人接手时,倒计时与聚焦/编辑决策归 Client 所有。无人接手时超时只中止前台请求的 signal 并返回 pending,不中止 Turn。`userQuestions` projection 从现有 Session 事件记录开放、已继续和已结算的计时调用:原生调用按记录的工具 schema 识别,PTC 子调用在结果为 pending 时进入投影。`answer` RPC 验证每个问题恰有一条回答,且每个已继续的调用只接收一条排队中的回复。回复被丢弃后,问题仍可回答。迟到批次保留在投影中,因为原工具结果包含的是超时而不是该回答。
|
||||
`UserQuestionService` 拥有每个 `TimedQuestionWait`、可取消的 Client 接手记录和无人接手计时器。有人接手时,倒计时与聚焦/编辑决策归 Client 所有。无人接手时超时只中止前台请求的 signal 并返回 pending,不中止 Turn。`userQuestions` projection 从现有 Session 事件记录开放、已继续和已结算的计时调用:原生调用按记录的工具 schema 识别,PTC 子调用在结果为 pending 时进入投影。`answer` RPC 验证每个问题恰有一条回答,且每个已继续的调用只接收一条排队中的回复;第二条回复以 `REPLY_QUEUED` 失败。回复被丢弃后,问题仍可回答。迟到批次保留在投影中,因为原工具结果包含的是超时而不是该回答。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
@@ -95,8 +95,14 @@ export class UserQuestionService extends TypertRemoteService {
|
||||
ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
|
||||
const source = message.source
|
||||
if (source.kind !== 'user-question-reply') return
|
||||
const reply = this.queuedReplies.get(agent.session)?.get(source.callId)
|
||||
if (reply?.messageId === message.id) reply.claimedTurn = turn
|
||||
const calls = this.queuedReplies.get(agent.session) ?? new Map<ToolCallId, QueuedReply>()
|
||||
const reply = calls.get(source.callId)
|
||||
if (reply === undefined) {
|
||||
calls.set(source.callId, { messageId: message.id, claimedTurn: turn })
|
||||
this.queuedReplies.set(agent.session, calls)
|
||||
} else if (reply.messageId === message.id) {
|
||||
reply.claimedTurn = turn
|
||||
}
|
||||
}, { global: true })
|
||||
ctx.on('agent/inbox/discarded', ({ agent, message }) => {
|
||||
const source = message.source
|
||||
@@ -149,9 +155,11 @@ export class UserQuestionService extends TypertRemoteService {
|
||||
* @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 had no reply already queued.
|
||||
* @returns Whether the question is still continued; an accepted reply stays
|
||||
* queued until the agent admits its user message.
|
||||
* @throws {UserQuestionError} `BAD_ANSWER` when the batch does not name each
|
||||
* question of the call exactly once.
|
||||
* question of the call exactly once, or `REPLY_QUEUED` when a reply is
|
||||
* already waiting for admission.
|
||||
*/
|
||||
@Remote
|
||||
answer(agent: Agent, callId: ToolCallId, answer: AskUserQuestionAnswer): boolean {
|
||||
@@ -161,7 +169,9 @@ export class UserQuestionService extends TypertRemoteService {
|
||||
const queued = this.queuedReplies.get(agent.session)
|
||||
const matches = (message: UserMessage): boolean =>
|
||||
message.source.kind === 'user-question-reply' && message.source.callId === callId
|
||||
if (queued?.has(callId) || agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return false
|
||||
if (queued?.has(callId) || agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) {
|
||||
throw new UserQuestionError('a reply is already queued for this question', 'REPLY_QUEUED')
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -665,7 +665,8 @@ describe('late replies', () => {
|
||||
const batch = { answers: [{ id: 'scope', selected: ['Tool only'] }] }
|
||||
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, batch)).toBe(true)
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, batch)).toBe(false)
|
||||
expect(() => ctx.userQuestions.answer(agent, timedCallId, batch))
|
||||
.toThrow(expect.objectContaining({ code: 'REPLY_QUEUED' }))
|
||||
expect(agent.steer).toHaveBeenCalledTimes(1)
|
||||
|
||||
const first = agent.steer.mock.calls[0]![0]
|
||||
@@ -689,14 +690,42 @@ describe('late replies', () => {
|
||||
})
|
||||
agent.queuedMessages.push(reply)
|
||||
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, batch)).toBe(false)
|
||||
expect(() => ctx.userQuestions.answer(agent, timedCallId, batch))
|
||||
.toThrow(expect.objectContaining({ code: 'REPLY_QUEUED' }))
|
||||
agent.queuedMessages.shift()
|
||||
agent.queuedTurns.push(reply)
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, batch)).toBe(false)
|
||||
expect(() => ctx.userQuestions.answer(agent, timedCallId, batch))
|
||||
.toThrow(expect.objectContaining({ code: 'REPLY_QUEUED' }))
|
||||
expect(agent.steer).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reserves a restored inbox reply while it is claimed for admission', async () => {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('late-restored-claim')
|
||||
ctx.agents.enter(agent, undefined)
|
||||
askInLog(agent, timedCallId)
|
||||
continueInLog(agent, timedCallId)
|
||||
const batch = { answers: [{ id: 'scope', selected: ['Tool only'] }] }
|
||||
const reply = createUserMessage({
|
||||
source: { kind: 'user-question-reply', callId: timedCallId, outcome: 'answered' },
|
||||
content: [{ type: 'text', text: 'restored reply' }],
|
||||
})
|
||||
agent.queuedMessages.push(reply)
|
||||
expect(() => ctx.userQuestions.answer(agent, timedCallId, batch))
|
||||
.toThrow(expect.objectContaining({ code: 'REPLY_QUEUED' }))
|
||||
|
||||
agent.queuedMessages.shift()
|
||||
agentEvents(ctx, agent).emit('agent/inbox/claimed', { message: reply, turn: 1 })
|
||||
expect(() => ctx.userQuestions.answer(agent, timedCallId, batch))
|
||||
.toThrow(expect.objectContaining({ code: 'REPLY_QUEUED' }))
|
||||
|
||||
const ended = agent.session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
|
||||
ctx.emit('session/event', agent.session, ended)
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, batch)).toBe(true)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps the reservation through unrelated inbox and Session events', async () => {
|
||||
const ctx = await timedContext()
|
||||
const agent = liveAgent('late-unrelated')
|
||||
@@ -729,7 +758,8 @@ describe('late replies', () => {
|
||||
const turnEnd = agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
ctx.emit('session/event', agent.session, turnEnd)
|
||||
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, batch)).toBe(false)
|
||||
expect(() => ctx.userQuestions.answer(agent, timedCallId, batch))
|
||||
.toThrow(expect.objectContaining({ code: 'REPLY_QUEUED' }))
|
||||
expect(agent.steer).toHaveBeenCalledOnce()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -746,7 +776,8 @@ describe('late replies', () => {
|
||||
const first = agent.steer.mock.calls[0]![0]
|
||||
agent.queuedMessages.shift()
|
||||
agentEvents(ctx, agent).emit('agent/inbox/claimed', { message: first, turn: 1 })
|
||||
expect(ctx.userQuestions.answer(agent, timedCallId, batch)).toBe(false)
|
||||
expect(() => ctx.userQuestions.answer(agent, timedCallId, batch))
|
||||
.toThrow(expect.objectContaining({ code: 'REPLY_QUEUED' }))
|
||||
|
||||
const ended = agent.session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
|
||||
ctx.emit('session/event', agent.session, ended)
|
||||
|
||||
Reference in new issue
Block a user