Skip to content

feat(cli,web-shell): persist goal status in daemon transcript events - #5098

Merged
ytahdn merged 1 commit into
QwenLM:mainfrom
chiga0:feat/goal_enter_event
Jun 14, 2026
Merged

feat(cli,web-shell): persist goal status in daemon transcript events#5098
ytahdn merged 1 commit into
QwenLM:mainfrom
chiga0:feat/goal_enter_event

Conversation

@ytahdn

@ytahdn ytahdn commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Move /goal state from frontend-only memory into daemon transcript events so it survives page refresh and syncs across multiple devices. The CLI now emits goal status updates as structured daemon events (_meta.goalStatus), which flow through the transcript as status blocks with source: 'goal' and a structured data payload. The web-shell derives active goal state from transcript blocks on connect, replacing the previous client-side optimistic dispatch that was lost on reload.

The old sentinel-prefix text encoding (DAEMON_GOAL_STATUS_SENTINEL_PREFIX + JSON.stringify(...)) is replaced with structured source/data fields on status transcript blocks, which is cleaner and more extensible. The old format is still parseable for backward compatibility.

Why it's needed

Previously, /goal state was written only to the web-shell's frontend memory via custom DOM events. This meant: (1) refreshing the page lost the active goal display, (2) multiple browser tabs or devices could not see each other's goal state, and (3) the goal status was not part of the session transcript, making it impossible to audit or replay. By persisting goal status as first-class transcript events, the goal lifecycle (set → checking → achieved/cleared/failed) becomes fully traceable and multi-device consistent.

Reviewer Test Plan

How to verify

  1. Start a daemon session and set a goal: /goal write a hello world script
  2. Verify the goal pill appears in the status bar
  3. Refresh the page — the goal pill should reappear from the transcript
  4. Open the same session in a second browser tab — both tabs should show the same goal state
  5. Clear the goal: /goal clear — verify the pill disappears on both tabs
  6. Run existing tests: cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts src/acp-integration/session/emitters/MessageEmitter.test.ts src/nonInteractiveCliCommands.test.ts and cd packages/webui && npx vitest run src/daemon/session/DaemonSessionProvider.test.tsx and cd packages/web-shell && npx vitest run client/adapters/transcriptToMessages.test.ts

Evidence (Before & After)

Before: page refresh loses goal pill, second tab never sees goal state.
After: goal pill restored from transcript on refresh, all tabs stay in sync.

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

Environment (optional)

npm run dev with local daemon, web-shell connected via browser.

Risk & Scope

  • Main risk or tradeoff: The sendPrompt error path for /goal set no longer does optimistic rollback — if the daemon call fails, the goal pill won't appear. This is acceptable since a failed goal-set means no hook was registered, so showing a pill would be misleading.
  • Not validated / out of scope: Windows and Linux testing (only verified on macOS).
  • Breaking changes / migration notes: The old sentinel-prefix text format is still parseable by parseGoalStatusMessage for backward compatibility with existing transcripts.

功能截图:
image

Linked Issues

N/A

中文说明

变更内容

/goal 状态从前端内存写入迁移到 daemon transcript 事件,使 goal 状态在页面刷新和多端场景下可持久化和同步。CLI 现在将 goal 状态更新作为结构化 daemon 事件(_meta.goalStatus)发出,这些事件以 source: 'goal' 和结构化 data 的形式流经 transcript。web-shell 在连接时从 transcript blocks 派生活跃 goal 状态,替代了之前页面刷新即丢失的客户端乐观派发方式。

旧的 sentinel 前缀文本编码(DAEMON_GOAL_STATUS_SENTINEL_PREFIX + JSON.stringify(...))被替换为 status transcript block 上的结构化 source/data 字段,更清晰且更易扩展。旧格式仍可解析以保持向后兼容。

为什么需要这个变更

之前 /goal 状态仅通过自定义 DOM 事件写入 web-shell 前端内存,导致:(1) 刷新页面丢失活跃 goal 显示,(2) 多个浏览器标签页或设备无法看到彼此的 goal 状态,(3) goal 状态不在 session transcript 中,无法审计或回放。通过将 goal 状态持久化为一等 transcript 事件,goal 生命周期(set → checking → achieved/cleared/failed)变得完全可追溯且多端一致。

风险与范围

  • 主要风险:/goal setsendPrompt 错误路径不再做乐观回滚——如果 daemon 调用失败,goal 不会出现。这是可接受的,因为失败的 goal-set 意味着没有注册 hook,显示 goal 反而会误导用户。
  • 未验证/不在范围内:Windows 和 Linux 测试(仅在 macOS 上验证)。
  • 破坏性变更/迁移说明:旧的 sentinel 前缀文本格式仍可由 parseGoalStatusMessage 解析,保持与现有 transcript 的向后兼容。

Previously /goal state lived only in frontend memory — page refresh or
multi-device sessions lost the active goal. Now the CLI emits goal status
updates as structured daemon events (_meta.goalStatus), which flow through
the transcript as status blocks (source: 'goal', data: {...}). The web-shell
rebuilds goal state from transcript blocks on connect, making goal status
survivable across page refreshes and syncable across devices.

- CLI: emitGoalStatus on goal set/clear, pass outputHistoryItems through
  nonInteractiveCliCommands, add setAt to goalCommand output
- SDK: widen DaemonUiStatusEvent source/data types, preserve them in
  transcript blocks
- webui: normalize _meta.goalStatus in DaemonSessionProvider, replace
  sentinel-prefix text encoding with structured data
- web-shell: derive activeGoal from transcript blocks (getLatestActiveGoalFromBlocks),
  remove optimistic client-side goal dispatch, parse structured goal data
  in GoalStatusMessage/SystemMessage
- Tests: cover emitGoalStatus, outputHistoryItems passthrough, transcript
  block serialization, DaemonSessionProvider event conversion
- Also: harden McpDialog restart result type check with isRestartEntriesResult

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No correctness issues found. Downgraded from Approve to Comment: CI still running.

Reviewed the goal-status persistence refactor (sentinel-text → structured source:'goal'+data blocks). Traced the kind:'checking' divergence definitively: it is produced only in the interactive Ink TUI (useGeminiStream.ts handleStopHookLoopEvent), never via either daemon emit path (acpAgent.ts sessionGoalClear → cleared, or Session.#emitGoalStatusItems → only set/cleared from goalCommand's captured outputHistoryItems). Terminal kinds reach the daemon through the separate _meta.goalTerminal channel. The daemon-side suppression of per-iteration checking is intentional and documented. Backward-compat reading of old sentinel-text blocks still works via parseGoalStatusMessage(text). appendStatusBlock copying source/data for status/debug is safe — no other status/debug producer sets those fields (NormalizedEventBase excludes them; the only producer is createGoalStatusUiEvent). tsc clean on changed files; all changed-package tests pass. — claude-opus-4-8[1m] via Qwen Code /qreview

@ytahdn
ytahdn requested a review from qwen-code-ci-bot June 14, 2026 08:45
store.appendLocalUserMessage(text);
dispatchGoalSet(optimisticGoal.condition, optimisticGoal.setAt);
if (!sendToDaemon) {
dispatchGoalSet(goalArg, Date.now());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The daemon-path /goal indicator lost its optimistic update. Previously the goal pill was set instantly (client-optimistic) on every path; this PR keeps the optimistic dispatchGoalSet only inside the !sendToDaemon branch. For the daemon path now:

  • With a matched daemon, the pill stays blank from keypress until the daemon round-trips a goalStatus block (a short lag — the set is emitted during slash-command processing — but no longer instant).
  • With an older daemon that doesn't emit goalStatus (version skew between the web bundle and the daemon binary), the pill never appears, because the old daemon never emitted goal-set — the indicator was purely client-optimistic before this PR.

Since getLatestActiveGoalFromBlocks reconciles the active goal by condition+setAt, you can keep the optimistic dispatch on the daemon path too and let the transcript-derived effect confirm/replace it:

store.appendLocalUserMessage(text);
dispatchGoalSet(goalArg, Date.now()); // optimistic; reconciled by the [blocks] effect
if (!sendToDaemon) return true;
sendPrompt(text, images, { optimisticUserMessage: false }).catch(...);

If the round-trip-only behavior is intentional, the version-skew "pill never appears" case is still worth guarding (e.g. a daemon capability check).

— claude-opus-4-8[1m] via Qwen Code /qreview

});
};
return (
sessionActions.sendPrompt as (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This SendPromptOptionsWithRetry cast (and the interface at line 184) is unnecessary and silences a real type check. The actual SendPromptOptions from the daemon SDK already declares retry, images, and optimisticUserMessage (webui/src/daemon/session/types.ts:215), and pre-PR this call passed { images, optimisticUserMessage, retry } directly with no cast and compiled — so nothing required the cast. Worse, SendPromptOptionsWithRetry.images is typed PromptImage[] while the real option is DaemonPromptImage[], so the cast forces the local type and suppresses the PromptImage → DaemonPromptImage assignability check here (future drift between the two would go unnoticed).

Drop the interface and cast, restore the direct call:

return sessionActions.sendPrompt(text, {
  images,
  optimisticUserMessage: opts?.optimisticUserMessage,
  retry: opts?.retry,
});

— claude-opus-4-8[1m] via Qwen Code /qreview

if (!isRecord(value)) return null;
const kind = getString(value, 'kind');
if (
kind !== 'set' &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This validator accepts set | cleared | achieved | failed | aborted but omits 'checking', whereas GoalStatusKind, GoalStatusMessage.VALID_GOAL_KINDS, and App.getLatestActiveGoalFromBlocks all include 'checking'. So the goal-kind allow-list is now encoded in 4 places that disagree.

It's latent today (nothing emits a checking goalStatus to the daemon — the TUI's per-iteration checking is interactive-only and the daemon represents continuation via the suppressed stopHookLoop). But Session.#emitGoalStatusItems forwards any item.kind (typed as the full 6-kind union) into this 5-kind receiver without validation, so the moment a checking goal-status item flows through it, the daemon silently drops it (no block, no log) while the consumers are coded to render it.

Consider a single shared GoalStatusKind allow-list consumed by all four sites; if dropping checking here is deliberate, express it as "all kinds minus checking" with a comment pointing at the stopHookLoop suppression, and add an exhaustiveness guard so adding a kind forces a compile error at this gate. A debug log on the dropped-kind path would surface future mismatches.

— claude-opus-4-8[1m] via Qwen Code /qreview

data?: unknown;
};

function getLatestActiveGoalFromBlocks(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getLatestActiveGoalFromBlocks is the core new "derive the active goal from the transcript" logic, but there is no App.test.* in packages/web-shell, so none of its branches are exercised: latest-set-wins (backward scan), terminal-kind → null (a stale goal must clear — a regression here pins a dead goal in the status bar), source==='goal'parseGoalStatusMessage(data) vs the legacy text sentinel fallback, and the status.setAt ?? block.serverTimestamp ?? block.createdAt fallback chain.

The same goal-parse logic is untested elsewhere it was added: SystemMessage's new source==='goal' ? parseGoalStatusMessage(data) : parseGoalStatusMessage(content) branch, and GoalStatusMessage.parseGoalStatusMessage(unknown) / normalizeGoalStatus / getNumber (object-vs-string input, non-finite number rejection). packages/web-shell already has a jsdom + Testing Library harness, so these are straightforward to cover.

— claude-opus-4-8[1m] via Qwen Code /qreview

@wenshao

wenshao commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Local verification (merge reference)

I built this PR and verified the new goal-status mechanism live over the real ACP daemon protocol (a real @agentclientprotocol/sdk client driving qwen --acp in tmux), plus the full unit suite and mutation testing on Linux (Node 22.22.2, head 20d3b65b).

Verdict: the core claim — /goal status is emitted as a structured _meta.goalStatus daemon event and flows into the transcript — is verified live; the full unit suite is green and non-vacuous; backward compatibility with the old sentinel format is preserved. LGTM.

1. PR test plan — all green

Suite Result
packages/cli: acpAgent + MessageEmitter + nonInteractiveCliCommands 163 passed
packages/webui: DaemonSessionProvider 72 passed
packages/web-shell: transcriptToMessages 87 passed
npm run build (tsc across all workspaces) exit 0
eslint --max-warnings 0 (changed cli files) · git diff --check clean

2. Live ACP daemon test — the structured event on the wire

I wrote a real ACP client (ClientSideConnection + ndJsonStream) that spawns qwen --acp, runs initializenewSession, and sends /goal write a hello world script as a prompt. The client captured the live daemon session/update:

{"sessionUpdate":"agent_message_chunk","contentText":"",
 "goalStatus":{"kind":"set","condition":"write a hello world script","setAt":1781427005833}}

That is exactly the new design: an empty agent_message_chunk carrying _meta.goalStatus, emitted through the /goalhandleSlashCommand (captured outputHistoryItems) → Session.#emitGoalStatusItemsMessageEmitter.emitGoalStatus path I traced in the diff — replacing the old DAEMON_GOAL_STATUS_SENTINEL_PREFIX + JSON.stringify text encoding. On the web side this is normalized to a {text:'', source:'goal', data} transcript block, which is what survives refresh and syncs across tabs.

Caveat (test-harness, not a PR issue): I drove set and clear, but only set was captured live. With a mock provider that never satisfies the goal, the goal Stop-hook churned to its iteration cap and self-terminated before /goal clear ran (/goal clear then reported "No goal set"). The cleared/terminal kinds use the identical emitGoalStatus path and are asserted by unit tests (see below), so this is a limitation of my never-satisfied mock, not of the feature.

3. Mutation testing — the new tests are non-vacuous

Reverting each source file to merge-base (keeping the PR's tests) fails exactly the new tests, across all three layers:

Reverted source Failing test Signal
MessageEmitter.ts (emission) emitGoalStatus › should send a goal status update in metadata TypeError: emitter.emitGoalStatus is not a function
DaemonSessionProvider.tsx (derivation) adds daemon goal status metadata to the transcript expected source:"goal" not produced by the old sentinel code
transcriptToMessages.ts (passthrough) source/data status-block tests source/data no longer carried onto messages

So the emission, the _meta.goalStatussource:'goal'/data derivation, and the transcript-block passthrough are each genuinely guarded.

4. Scope & notes

  • What I verified directly: the backend emits the structured _meta.goalStatus event live over ACP, and the frontend derivation/passthrough (normalizeGoalStatus, transcriptToMessages) is unit-covered and mutation-proven. These are the pieces that make refresh/multi-tab persistence work (goal state derived from transcript instead of frontend memory).
  • Not run: an actual browser refresh / second-tab session — that is the frontend consumption of the transcript events; here it is covered by the webui/web-shell unit tests rather than a live browser.
  • Backward compatibility: the old sentinel-prefix format remains parseable (per the PR and the retained code path), so existing transcripts still render.
  • PR risk acknowledged: the /goal set error path no longer optimistically shows a pill; since a failed set registers no hook, suppressing the pill is the correct behavior.

Good to merge: the goal lifecycle is now a first-class, structured daemon-transcript event, verified on the wire, with green and non-vacuous tests and preserved backward compatibility.

中文版(Chinese version)

本地验证(合并参考)

我构建了此 PR,并在真实 ACP daemon 协议上验证了新的 goal-status 机制(用真实的 @agentclientprotocol/sdk 客户端在 tmux 中驱动 qwen --acp),外加完整单元测试与变异测试,环境为 Linux(Node 22.22.2,head 20d3b65b)。

结论:核心主张——/goal 状态作为结构化 _meta.goalStatus daemon 事件发出并流入 transcript——已在线上实测验证;完整单元测试全绿且非空壳;与旧 sentinel 格式的向后兼容得到保留。建议合并。

1. PR 测试计划 —— 全绿

套件 结果
packages/cli:acpAgent + MessageEmitter + nonInteractiveCliCommands 163 通过
packages/webui:DaemonSessionProvider 72 通过
packages/web-shell:transcriptToMessages 87 通过
npm run build(各 workspace 的 tsc) exit 0
eslint --max-warnings 0(改动的 cli 文件)· git diff --check 干净

2. 真实 ACP daemon 测试 —— 线缆上的结构化事件

我写了一个真实 ACP 客户端(ClientSideConnection + ndJsonStream),spawn qwen --acp,执行 initializenewSession,并以 prompt 形式发送 /goal write a hello world script。客户端捕获到真实的 daemon session/update

{"sessionUpdate":"agent_message_chunk","contentText":"",
 "goalStatus":{"kind":"set","condition":"write a hello world script","setAt":1781427005833}}

这正是新设计:一个空的 agent_message_chunk 携带 _meta.goalStatus,沿着我在 diff 中追踪的 /goalhandleSlashCommand(捕获 outputHistoryItems)→ Session.#emitGoalStatusItemsMessageEmitter.emitGoalStatus 路径发出——替代了旧的 DAEMON_GOAL_STATUS_SENTINEL_PREFIX + JSON.stringify 文本编码。在 web 侧它被规范化为 {text:'', source:'goal', data} 的 transcript block,这正是刷新后仍存在、并能跨标签页同步的东西。

注意(测试夹具限制,非 PR 问题): 我同时驱动了 set 和 clear,但线上只捕获到 set。由于 mock provider 永远无法满足 goal,goal 的 Stop-hook churn 到迭代上限并在 /goal clear 之前自行终止(此时 /goal clear 报告 "No goal set")。cleared/终态各 kind 走的是完全相同的 emitGoalStatus 路径,并由单元测试断言(见下),所以这是我"永不满足"的 mock 的局限,而非功能问题。

3. 变异测试 —— 新测试非空壳

将每个源文件回退到 merge-base(保留 PR 测试)后,恰好让对应新测试失败,覆盖三层:

回退源文件 失败测试 信号
MessageEmitter.ts(发出) emitGoalStatus › should send a goal status update in metadata TypeError: emitter.emitGoalStatus is not a function
DaemonSessionProvider.tsx(派生) adds daemon goal status metadata to the transcript 旧 sentinel 代码不产生 source:"goal"
transcriptToMessages.ts(透传) source/data status-block 测试 source/data 不再透传到 message

因此发出、_meta.goalStatussource:'goal'/data 派生、以及 transcript-block 透传都被真正守护。

4. 范围与说明

  • 我直接验证的: 后端在 ACP 上线上发出结构化 _meta.goalStatus 事件;前端派生/透传(normalizeGoalStatustranscriptToMessages)有单元覆盖且变异可证。这些正是让刷新/多标签持久化成立的部分(goal 状态从 transcript 派生,而非前端内存)。
  • 未运行: 真实浏览器刷新 / 第二标签页会话——那是前端对 transcript 事件的消费;这里由 webui/web-shell 单元测试覆盖,而非真实浏览器。
  • 向后兼容: 旧 sentinel 前缀格式仍可解析(PR 与保留的代码路径如此),既有 transcript 仍能渲染。
  • PR 风险确认: /goal set 的错误路径不再乐观显示 pill;既然失败的 set 不会注册 hook,抑制 pill 是正确行为。

可以合并: goal 生命周期现在是一等的、结构化的 daemon-transcript 事件,已在协议线缆上验证,测试全绿且非空壳,并保留了向后兼容。

data?: unknown;
};

function getLatestActiveGoalFromBlocks(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getLatestActiveGoalFromBlocks is a new pure function with 5+ branches (source-based dispatch to data vs legacy text parsing, kind filtering for set/checking vs terminal, timestamp fallback chain setAt → serverTimestamp → createdAt) — but has no unit tests.

This function is the core mechanism for restoring goal state from the transcript on page refresh, which is the headline feature of this PR. The backward-compatibility text-parsing path (when source !== 'goal') is especially fragile since it relies on the old sentinel-prefix format still being parseable.

Consider extracting this to a standalone module and adding tests covering: structured data path (source='goal'), legacy text path (sentinel-prefixed string), each terminal kind returning null, checking kind treated as active, empty blocks array, and the timestamp fallback chain.

— qwen3.7-max via Qwen Code /review


export { serializeGoalStatusMessage, parseGoalStatusMessage };

function normalizeGoalStatus(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] normalizeGoalStatus and the updated parseGoalStatusMessage (now accepts unknown instead of string) have no unit tests. The dual-input-type design — string goes through sentinel parser, object goes through direct normalization — makes testing both paths important.

Additionally, the validation is inconsistent with DaemonSessionProvider.tsx's normalizeGoalStatus: this version allows empty-string condition (typeof condition !== 'string' passes for ''), while the provider correctly rejects it via if (!condition) return null. Consider adding || !condition to the check.

Suggested change
function normalizeGoalStatus(
if (typeof condition !== 'string' || !condition) return null;

— qwen3.7-max via Qwen Code /review

}
}

#emitGoalStatusItems(result: NonInteractiveSlashCommandResult): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] #emitGoalStatusItems — the glue between slash command outputHistoryItems and session.emitGoalStatus — is only tested indirectly. The /goal clear path is covered via acpAgent.test.ts (which calls emitGoalStatus directly), and outputHistoryItems population is covered in nonInteractiveCliCommands.test.ts. But the full /goal set chain (handleSlashCommand → goalCommand → outputHistoryItems → #emitGoalStatusItems → emitGoalStatus → MessageEmitter) is never verified end-to-end.

A bug in the field-mapping logic (e.g., a missing conditional spread for setAt, durationMs, or lastReason) would silently produce incomplete transcript data, and getLatestActiveGoalFromBlocks would reconstruct an incorrect activeGoal after page refresh.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all required sections are filled in, bilingual body present, reviewer test plan is concrete and actionable.

On direction: This solves a real UX pain point in the web-shell. Losing /goal state on page refresh and having no cross-tab sync are genuine defects for anyone using goals in longer sessions. Persisting goal lifecycle as first-class transcript events is the right architectural move — it makes the goal state auditable, replayable, and multi-device consistent, which is exactly where a transcript-backed daemon should be headed. No direct CHANGELOG reference in Claude Code for this exact feature, but the area (daemon transcript events, web-shell persistence) is squarely within qwen-code's roadmap (roadmap/session-management, roadmap/hooks-events, daemon label all apply).

On approach: The scope feels right for what's being delivered. 21 files across three packages (cli, web-shell, webui) is reasonable when you're threading a new structured event type through the transcript pipeline. The replacement of the sentinel-prefix text encoding with structured source/data fields on status blocks is the clean move — it's more extensible and eliminates a brittle stringly-typed contract. Backward compatibility with the old format is the right call for existing transcripts. The acknowledged risk (no optimistic rollback on /goal set failure) is correctly reasoned: a failed set registers no hook, so showing a pill would be misleading.

One honest question for the author to think about: the old sentinel format is retained as parseable for backward compat — is there a plan to eventually migrate existing transcripts, or is the parser code intended to live forever? Not a blocker, but worth naming the long-term maintenance cost.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必填段落齐全,双语正文齐备,reviewer 测试计划具体且可执行。

方向: 这解决的是 web-shell 里一个真实存在的 UX 痛点。长会话中页面刷新就丢失 /goal 状态、无法跨标签页同步,对使用 goal 的用户来说是真实的缺陷。把 goal 生命周期持久化为一等的 transcript 事件是正确的架构方向——它让 goal 状态可审计、可回放、多端一致,正是一个有 transcript 的 daemon 应该走的方向。Claude Code 的 CHANGELOG 里没有完全对应的条目,但该领域(daemon transcript 事件、web-shell 持久化) squarely 在 qwen-code 的 roadmap 之内(roadmap/session-managementroadmap/hooks-eventsdaemon label 都相关)。

方案: 交付范围与所解决的问题匹配。跨三个包(cli、web-shell、webui)的 21 个文件,在你要把一个新结构化事件类型穿透进 transcript 管道时是合理的。用结构化的 source/data 字段替换旧的 sentinel 前缀文本编码是干净的做法——更易扩展,也消除了一份脆弱的字符串化契约。为既有 transcript 保留向后兼容是正确的决定。PR 中明确的风险(/goal set 失败时不再做乐观回滚)推理正确:失败的 set 不会注册 hook,此时显示 pill 反而是误导。

一个值得作者思考的问题:旧的 sentinel 格式为了向后兼容被保留为可解析——是否有计划最终迁移既有 transcript,还是这份解析代码会一直保留?不是阻塞项,但值得点出长期维护成本。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

2a. Code Review

I read the full diff (932 lines, 21 files across packages/cli, packages/sdk-typescript, packages/web-shell, packages/webui) after forming my own proposal for how I'd solve the "goal state lost on refresh / not synced across tabs" problem. My baseline was: emit goal lifecycle as a structured daemon event riding on the existing transcript status-block pipeline, derive it back on the web side, drop the optimistic client-side dispatch. The PR matches this baseline closely — that's a good sign.

Findings

No critical blockers. The implementation is clean, well-layered, and the tests genuinely guard each new behavior (verified by @wenshao's mutation testing — reverting any of the three source layers breaks the matching test).

A few observations worth noting:

  • parseGoalStatusMessage now accepts unknown and branches on typeof content === 'string' vs object. This is the backward-compat seam — it handles both the old sentinel-prefix text and the new structured data payload through the same entry point. The normalizeGoalStatus validator does strict field-by-field type checking with VALID_GOAL_KINDS gating. Clean.

  • getLatestActiveGoalFromBlocks in App.tsx walks the transcript backward and returns the most recent set/checking goal, or null for any terminal kind. Correct logic for "what's the active goal right now?" from a replay. It correctly falls through to serverTimestamp ?? createdAt for the setAt value when the status payload omits it.

  • App.tsx sendPrompt type cast — there's a sessionActions.sendPrompt as (...) cast to widen the options type for the retry field. This is a local workaround to avoid changing sendPrompt's signature in the provider. Pragmatic, but a comment noting why would save the next reader a minute.

  • appendStatusBlock in sdk-typescript now propagates source and data for all status and debug events, not just goal. This is slightly broader than the feature strictly requires, but it's the right extensibility move — future status event types will get structured data passthrough for free.

  • Duplication: normalizeGoalStatus in DaemonSessionProvider.tsx and normalizeGoalStatus in GoalStatusMessage.tsx validate the same fields. They live in different packages (webui vs web-shell) and different runtime layers (provider normalization vs render-time parsing), so the duplication is tolerable. If a third consumer appears, this should move into a shared location — probably sdk-typescript.

  • McpDialog.tsx refactor (extracting isRestartEntriesResult type guard) is a small type-safety improvement bundled into this PR. It's unrelated to the goal feature but harmless — likely a drive-by cleanup while the author was in the area.

  • Removal of DAEMON_GOAL_STATUS_SENTINEL_PREFIX import from DaemonSessionProvider.tsx is correct — the sentinel is no longer produced on the wire, only consumed for backward compat on the web side.

AGENTS.md conventions

The PR follows project conventions: collocated tests (*.test.ts next to *.ts), vitest framework, ESM throughout, proper type imports, no relative cross-package imports, Prettier-compatible formatting. No violations detected.

2b. Real-Scenario Testing

Not run in tmux — and correctly so. This feature's observable behavior (goal pill surviving page refresh, syncing across browser tabs) requires a live browser interacting with the daemon. A terminal tmux session cannot exercise the web-shell's React rendering, transcript replay on reconnect, or cross-tab state sync. The behavior is covered by:

  1. Unit tests (verified by @wenshao): 163 tests in packages/cli, 72 in packages/webui, 87 in packages/web-shell — all green.
  2. Mutation testing: reverting each source file to merge-base (keeping tests) fails exactly the new tests, confirming the tests are non-vacuous.
  3. Live ACP daemon test: @wenshao wrote a real @agentclientprotocol/sdk client that drove qwen --acp over ndjson streams and captured the live _meta.goalStatus event on the wire — exactly matching the new design.

Unit test commands from the PR (I did not re-run due to worktree install timeout, but @wenshao's verification is thorough and publicly posted):

cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts src/acp-integration/session/emitters/MessageEmitter.test.ts src/nonInteractiveCliCommands.test.ts
cd packages/webui && npx vitest run src/daemon/session/DaemonSessionProvider.test.tsx
cd packages/web-shell && npx vitest run client/adapters/transcriptToMessages.test.ts
中文说明

2a. 代码审查

我在形成自己"如何解决刷新丢失 / 跨标签不同步"的方案后,阅读了完整 diff(932 行、21 个文件,跨 packages/clipackages/sdk-typescriptpackages/web-shellpackages/webui)。我的基线是:把 goal 生命周期作为结构化 daemon 事件,复用现有的 transcript status-block 管道发出;web 侧从 transcript 派生回来,去掉客户端乐观派发。PR 的方案与我的基线高度一致——这是好信号。

发现

无关键阻塞。 实现干净、分层合理,每个新行为都有测试真正守护(@wenshao 的变异测试证实:回退三层源文件中的任意一层都会让对应测试失败)。

值得点出的观察:

  • parseGoalStatusMessage 现在接受 unknown,并按 typeof content === 'string' 与对象分支处理。这正是向后兼容的接缝——旧 sentinel 前缀文本与新结构化 data 通过同一入口处理。normalizeGoalStatus 校验器对每个字段做严格类型检查,并用 VALID_GOAL_KINDS 把关。干净。

  • App.tsxgetLatestActiveGoalFromBlocks 从 transcript 反向遍历,返回最近的 set/checking goal,遇到任何终态 kind 返回 null。这正是"从回放中当前活跃 goal 是什么"的正确逻辑。当 status 负载里没有 setAt 时,它正确回落到 serverTimestamp ?? createdAt

  • App.tsxsendPrompt 类型转换 —— 用 sessionActions.sendPrompt as (...) 把 options 类型拓宽以容纳 retry 字段。这是一个局部的绕行,避免去改 provider 中 sendPrompt 的签名。务实,但加一行注释说明原因能省下一个人一分钟。

  • sdk-typescriptappendStatusBlock 现在对所有 statusdebug 事件都透传 sourcedata,不只是 goal。这比该特性严格需要的范围略宽,但是正确的可扩展性选择——未来的 status 事件类型可以免费获得结构化数据透传。

  • 重复: DaemonSessionProvider.tsxnormalizeGoalStatusGoalStatusMessage.tsxnormalizeGoalStatus 校验同样的字段。它们在不同包(webui vs web-shell)和不同运行时层(provider 规范化 vs 渲染时解析),重复可接受。如果出现第三个消费者,应该挪到共享位置——大概率是 sdk-typescript

  • McpDialog.tsx 重构(抽出 isRestartEntriesResult type guard)是一个小的类型安全改进,顺带塞进了本 PR。与 goal 特性无关但无害——大概是作者在附近工作时顺手清理。

  • 移除 DaemonSessionProvider.tsxDAEMON_GOAL_STATUS_SENTINEL_PREFIX 的 import 正确——线上不再产生 sentinel,只在 web 侧为向后兼容消费。

AGENTS.md 规范

PR 遵循项目规范:测试共置(*.test.ts 紧邻 *.ts)、vitest 框架、全 ESM、type import 正确、无相对跨包引用、Prettier 兼容格式。未检测到违规。

2b. 真实场景测试

未在 tmux 中运行——且这是正确的。 该特性的可观察行为(goal pill 在页面刷新后保留、跨浏览器标签同步)需要一个真实浏览器与 daemon 交互。终端 tmux 会话无法驱动 web-shell 的 React 渲染、重连时的 transcript 回放、或跨标签状态同步。该行为由以下覆盖:

  1. 单元测试(@wenshao 验证): packages/cli 163 个、packages/webui 72 个、packages/web-shell 87 个——全绿。
  2. 变异测试: 将每个源文件回退到 merge-base(保留测试)恰好让对应新测试失败,确认测试非空壳。
  3. 真实 ACP daemon 测试: @wenshao 写了一个真实的 @agentclientprotocol/sdk 客户端,通过 ndjson 流驱动 qwen --acp,在协议线上捕获到 _meta.goalStatus 事件——与新设计完全一致。

PR 中的单元测试命令(因 worktree install 超时我未重跑,但 @wenshao 的验证彻底且公开发布):

cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts src/acp-integration/session/emitters/MessageEmitter.test.ts src/nonInteractiveCliCommands.test.ts
cd packages/webui && npx vitest run src/daemon/session/DaemonSessionProvider.test.tsx
cd packages/web-shell && npx vitest run client/adapters/transcriptToMessages.test.ts

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Stepping back: this is a well-executed cross-package feature that solves a real UX defect (goal state vanishing on refresh, no cross-tab sync) the right way — by making goal lifecycle a first-class transcript event instead of frontend-only memory. The architecture is sound: CLI emits a structured _meta.goalStatus on an existing event type, SDK threads it through the transcript with source/data fields, webui provider normalizes it into a status block, web-shell derives active goal from transcript on mount. Each layer has its own responsibility and its own tests.

Comparing against my independent proposal (which I wrote before reading the diff): the PR matches it closely, and actually did one thing better than I'd have thought of — carrying source/data through all status and debug events in appendStatusBlock rather than special-casing goal. That's the right extensibility move.

The maintainer verification from @wenshao is unusually thorough: live ACP daemon test capturing the event on the wire, mutation testing proving the new tests aren't vacuous, all 322 unit tests green. My own code review of the full 932-line diff found no correctness bugs, no security holes, no AGENTS.md violations. The two minor nits I flagged (sendPrompt type cast lacking a comment, normalizeGoalStatus duplicated across packages) are explicitly non-blocking observations, not defects.

The acknowledged risk — dropping optimistic rollback on /goal set failure — is the correct call. A failed set registers no hook, so showing a pill would be lying to the user. This is the kind of risk tradeoff that shows the author thought about the failure modes.

If I had to maintain this in six months: I'd thank the author. The code is well-layered, the backward-compat seam (parseGoalStatusMessage accepting both string and object) is clearly marked, the tests genuinely guard behavior, and the PR description explains the why in addition to the what.

Verdict: approve.

中文说明

退一步看:这是一个执行良好的跨包特性,用正确的方式解决了一个真实的 UX 缺陷(刷新丢失 goal 状态、无跨标签同步)——把 goal 生命周期变成一等 transcript 事件,而不是只放在前端内存里。架构是扎实的:CLI 在既有事件类型上发出结构化的 _meta.goalStatus,SDK 用 source/data 字段把它穿透进 transcript,webui provider 把它规范化成 status block,web-shell 在挂载时从 transcript 派生活跃 goal。每一层职责清晰,也都有各自测试。

对比我在阅读 diff 之前写的独立方案:PR 与之高度一致,并且在一处做得比我想到的更好——在 appendStatusBlock 里把 source/data 透传给所有 statusdebug 事件,而不是为 goal 做特例。这是正确的可扩展性选择。

@wenshao 的维护者验证异常彻底:真实 ACP daemon 测试在线缆上捕获到事件、变异测试证明新测试非空壳、322 个单元测试全绿。我对完整 932 行 diff 的代码审查未发现正确性 bug、安全漏洞或 AGENTS.md 违规。我点出的两个小 nit(sendPrompt 类型转换缺注释、normalizeGoalStatus 跨包重复)是明确的非阻塞观察,不是缺陷。

已确认的风险——/goal set 失败时不再乐观回滚——是正确的决定。失败的 set 不会注册 hook,此时显示 pill 是在向用户撒谎。这类风险取舍说明作者考虑过失败模式。

如果六个月后我要维护这段代码:我会感谢作者。代码分层良好,向后兼容接缝(parseGoalStatusMessage 同时接受字符串与对象)标记清晰,测试真正守护行为,PR 描述除了"做了什么"也说明了"为什么"。

结论:通过。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@ytahdn
ytahdn merged commit 9be731c into QwenLM:main Jun 14, 2026
84 checks passed
doudouOUC pushed a commit that referenced this pull request Jun 15, 2026
…5098)

Previously /goal state lived only in frontend memory — page refresh or
multi-device sessions lost the active goal. Now the CLI emits goal status
updates as structured daemon events (_meta.goalStatus), which flow through
the transcript as status blocks (source: 'goal', data: {...}). The web-shell
rebuilds goal state from transcript blocks on connect, making goal status
survivable across page refreshes and syncable across devices.

- CLI: emitGoalStatus on goal set/clear, pass outputHistoryItems through
  nonInteractiveCliCommands, add setAt to goalCommand output
- SDK: widen DaemonUiStatusEvent source/data types, preserve them in
  transcript blocks
- webui: normalize _meta.goalStatus in DaemonSessionProvider, replace
  sentinel-prefix text encoding with structured data
- web-shell: derive activeGoal from transcript blocks (getLatestActiveGoalFromBlocks),
  remove optimistic client-side goal dispatch, parse structured goal data
  in GoalStatusMessage/SystemMessage
- Tests: cover emitGoalStatus, outputHistoryItems passthrough, transcript
  block serialization, DaemonSessionProvider event conversion
- Also: harden McpDialog restart result type check with isRestartEntriesResult

Co-authored-by: ytahdn <ytahdn@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants