From 074cdccdb9e735058611388c38fb4dea0f0d031a Mon Sep 17 00:00:00 2001 From: Pan <503103216@qq.com> Date: Sun, 4 Oct 2026 19:24:02 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(client):=20=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E4=B8=8E=E6=8F=90=E9=97=AE=E6=8F=90=E9=86=92=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A2=AB=E8=BF=90=E8=A1=8C=E7=8A=B6=E6=80=81=E4=B8=A2?= =?UTF-8?q?=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/core/copy.js | 39 ++++++++++++++++--------------- src/client/core/delivery.js | 24 ++++++++++++++----- src/client/core/session.js | 46 +++++++++++++++++++++++++++---------- 3 files changed, 73 insertions(+), 36 deletions(-) diff --git a/src/client/core/copy.js b/src/client/core/copy.js index 1d7c87b..557df2d 100644 --- a/src/client/core/copy.js +++ b/src/client/core/copy.js @@ -8,7 +8,8 @@ /** * Build the copy table for one translate function. * @param t - the translate function every string here goes through. - * @returns `copyFor` (candidate → notice) and the two diagnosis renderers. + * @returns `copyFor` (candidate → notice), `deliveryFacts` and `replayValue` + * (the two status values the settings page shows). */ export function createCopy(t) { /** Build the delivered copy for one candidate. */ @@ -17,6 +18,12 @@ export function createCopy(t) { if (candidate.kind === 'completion') { return { kind: 'completion', title: t('notification.completion'), body: t('body.completion', { title }) } } + // A plan review arrives as a question with its own discriminator, so it is + // answered before the question branch below — a plan review says what it is, + // rather than repeating the plan text the user has not read yet. + if (candidate.pendingKind === 'plan-review') { + return { kind: 'question', title: t('notification.question'), body: t('body.planReview', { title }) } + } if (candidate.kind === 'question') { const question = candidate.detail return { @@ -25,15 +32,6 @@ export function createCopy(t) { body: question === '' ? t('body.question', { title }) : `${title} · ${question}`, } } - // Kept in v1.0.6's order, which makes this branch unreachable today: the - // observer queues a plan review as `kind: 'question'`, so the branch above - // already answered with the plan's own text. Restoring the documented copy - // (`body.planReview`, "计划正在等待你确认") means testing `pendingKind` - // before `kind` — a user-visible behavior change, so it is deliberately NOT - // part of this refactor. `tests/unit/copy.test.js` pins today's output. - if (candidate.pendingKind === 'plan-review') { - return { kind: 'question', title: t('notification.question'), body: t('body.planReview', { title }) } - } const tool = candidate.detail return { kind: 'approval', @@ -42,9 +40,14 @@ export function createCopy(t) { } } - /** Describe the newest alert's fate in one line. */ - const describeDelivery = (last, tr) => { - if (last === null || last === undefined) return tr('config.diag.none') + /** + * Split the newest delivery into the three facts the status list shows. + * @param last - `store.lastDelivery`. + * @param tr - the translate function the outcome label is rendered with. + * @returns `{ at, outcome, title }`, or null when nothing was ever delivered. + */ + const deliveryFacts = (last, tr) => { + if (last === null || last === undefined) return null const outcome = last.outcome.startsWith('system-refused') ? tr('config.diag.refused') : last.outcome === 'system' @@ -56,13 +59,13 @@ export function createCopy(t) { : last.outcome === 'replay' ? tr('config.diag.replayDelivery') : tr('config.diag.popup') - return `${tr('config.diag.last')}: ${last.at} · ${outcome} · ${last.title}` + return { at: last.at, outcome, title: last.title ?? '' } } - /** Say how many away-channel alerts are still waiting to be replayed in-app. */ - const describeReplay = (count, tr) => ( - count === 0 ? tr('config.diag.replayNone') : tr('config.diag.replay', { count }) + /** How many away-channel alerts are still waiting for the user to come back. */ + const replayValue = (count, tr) => ( + count === 0 ? tr('config.diag.replayNone') : tr('config.diag.replayValue', { count }) ) - return { copyFor, describeDelivery, describeReplay } + return { copyFor, deliveryFacts, replayValue } } diff --git a/src/client/core/delivery.js b/src/client/core/delivery.js index 3cf0fcc..d2b9d08 100644 --- a/src/client/core/delivery.js +++ b/src/client/core/delivery.js @@ -7,7 +7,7 @@ import { SETTLE_MS, THROTTLE_MS } from '../constants.js' import { windowIsAway } from '../platform.js' -import { isOnScreen, stillWorth } from './session.js' +import { isOnScreen, stillOwed } from './session.js' /** * Build the delivery path. @@ -67,29 +67,33 @@ export function createDelivery({ store, copy, live, toasts, system, replay }) { * never actually receives. * @param candidate - the alert to deliver. * @param ignoreThrottle - deliver immediately, bypassing the flood guard. + * @returns `false` when the flood guard suppressed this alert, `true` when the + * delivery path itself ran (including a deliberate skip for the conversation + * on screen). */ const deliver = (candidate, ignoreThrottle) => { if (ignoreThrottle !== true) { const now = Date.now() - if (now - lastDelivery < THROTTLE_MS) return + if (now - lastDelivery < THROTTLE_MS) return false lastDelivery = now } if (!windowIsAway()) { - if (isOnScreen(candidate.sessionId, live.list)) return + if (isOnScreen(candidate.sessionId, live.list)) return true toasts.showToast(candidate) recordDelivery('popup', candidate) - return + return true } const result = system.notifySystem(candidate, candidate.test === true) if (result.outcome === 'raised') { recordDelivery('system', candidate, result) if (candidate.test !== true) replay.holdForReplay(candidate, result) - return + return true } // A test alert still has to reach the user, and so does a real one when // the system channel refuses: the popup is the channel that remains. toasts.showToast(candidate) recordDelivery(`system-refused-${result.outcome}`, candidate) + return true } /** @@ -98,11 +102,19 @@ export function createDelivery({ store, copy, live, toasts, system, replay }) { * One candidate per tick keeps the throttle meaningful, and anything still * queued arms its own follow-up tick — a burst used to leave every candidate * after the first stranded in the queue with no timer to flush it. + * + * A pending interaction the flood guard holds back is put back in the queue + * instead of being dropped: only the user can clear it, so it stays owed, and + * the next tick retries it. Flood control still drops the completions it was + * written for. */ const flushSettle = () => { settleTimer = 0 const candidate = pending.shift() - if (candidate !== undefined && stillWorth(candidate, live)) deliver(candidate, false) + if (candidate !== undefined && stillOwed(candidate, live)) { + const delivered = deliver(candidate, false) + if (delivered === false && candidate.pendingKind !== '') pending.push(candidate) + } if (pending.length > 0) settleTimer = setTimeout(() => flushSettle(), SETTLE_MS) } diff --git a/src/client/core/session.js b/src/client/core/session.js index 15533a7..5a9543c 100644 --- a/src/client/core/session.js +++ b/src/client/core/session.js @@ -8,9 +8,13 @@ import { PENDING_KINDS } from '../constants.js' /** - * Discriminate a pending interaction the client published. Only the three - * domains the Harness itself renders are served; anything else is ignored - * rather than guessed at. + * Discriminate a pending interaction the client published. + * + * The domains that can ask the user something are `@deepseek-ai/dsh-client-ui-approval` + * (a tool authorisation) and `@deepseek-ai/dsh-client-ui-user-questions` (a + * question batch, or the plan review its `planReviewOf` marks); each publishes a + * literal `kind` on its pending value. Anything else is ignored rather than + * guessed at. * @param value - `status.pendingInteraction`. * @returns a served kind, or undefined. */ @@ -55,21 +59,39 @@ export function isOnScreen(sessionId, list) { } /** - * Whether a queued candidate is still worth delivering. Read from the LIVE - * snapshot rather than the one captured when the transition was seen: a - * conversation that resumed in the settle window owes no alert, and the - * snapshot the transition was derived from still carries the pre-transition - * `running` flag of the session record. + * Whether a queued candidate is still owed, read from the LIVE snapshot rather + * than the one captured when the transition was seen. + * + * The two triggers are owed for different reasons, so they are checked for + * different things: + * + * - A **pending interaction** (approval, question, plan review) is owed until + * the user answers it. It is deliberately NOT gated on the run state: the + * session is `running` while it waits — the client's own `observeRunning` + * only mirrors `api-session/status` and says nothing about a pending request — + * so a running gate would silently drop every approval and question alert. + * - A **completion** is owed only while the conversation stayed idle. A + * conversation that resumed inside the settle window owes no alert, and the + * snapshot the transition was derived from still carries the pre-transition + * `running` flag of the session record. * @param candidate - the alert waiting for its settle tick. * @param live - the freshest `{ list, status }` the observer has seen. * @returns whether the alert is still owed. */ -export function stillWorth(candidate, live) { +export function stillOwed(candidate, live) { + if (candidate.test === true) return true + + if (candidate.pendingKind !== '') { + const sessionStatus = live.status?.get?.(candidate.sessionId) + // No status entry contradicts the request, so it is still the user's move. + if (sessionStatus === undefined) return true + return servedPendingKind(sessionStatus.pendingInteraction) === candidate.pendingKind + } + const list = live.list - if (candidate.test === true || list === undefined) return true + if (list === undefined) return true const summary = list.byId?.[candidate.sessionId] if (summary === undefined) return true - const status = live.status - const running = status?.get?.(candidate.sessionId)?.running ?? summary.running + const running = live.status?.get?.(candidate.sessionId)?.running ?? summary.running return running !== true }