Skip to content

feat(goal): show what a Goal has spent against the window it is allowed - #11248

Merged
qqqys merged 2 commits into
QwenLM:mainfrom
qqqys:feat/goal-show-budget-tui
Sep 7, 2026
Merged

feat(goal): show what a Goal has spent against the window it is allowed#11248
qqqys merged 2 commits into
QwenLM:mainfrom
qqqys:feat/goal-show-budget-tui

Conversation

@qqqys

@qqqys qqqys commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Every surface that shows a Goal now also shows what it has spent against the window it is allowed. The footer pill reads ◎ /goal active (28s · 147.0k/30.0m), the status card's subtitle gains a 80.7k/30.0m tokens stat, and headless status gains a Usage: line -- in the TEXT output and, because that string is the single final message, in result.summary under --output-format json as well.

Three display rules, applied identically at every site. A Goal that has not billed a turn shows no figures: a fresh 0/30.0m says nothing the status has not already said, and the pill sits in a footer with little room to say it. A Goal with no budget shows what it has spent, since that is the whole of what is known about it. A stopped Goal keeps showing its spend, because what a paused or blocked Goal cost is exactly what the user is deciding about when they choose whether to resume.

The pill and the cards abbreviate through the formatTokenCount the status line already uses. The headless line spells the figures out instead, because that output is read in terminal scrollback and piped into scripts, and neither is helped by 1.2k.

Two things beyond a literal reading of the change. The OpenTUI card computes its own subtitle from its own snapshot type, so both were extended alongside the ink card rather than left to drift apart under the parity gate. And formatGoalState is exported so its output can be pinned directly: the states worth checking, a Goal mid-run, one with no budget, one that has billed nothing, are far cheaper to construct as snapshots than to drive a headless run into, and the file already exports skipHeadlessLoopSentinel for the same reason.

Why it's needed

The spend window is enforced but invisible. tokensUsed and tokenBudget have been on the Goal record since budgets landed, the 30,000,000 default stops the Goal and hands it back to the user, and until now nothing showed how close it was. Grepping tokensUsed across the TUI and the Web Shell returned test fixtures and nothing else.

So the user watched the turn count climb with no way to tell a Goal two percent into its window from one about to stop. Codex puts the same pair in its TUI status line as 63.9K/50K; this is the equivalent, at the surfaces this CLI already uses to say what a Goal is doing.

Web Shell is deliberately not in this PR. Its strip and dialog read the hand-copied SDK GoalRecord, which lacks both fields, so it needs a type change first and is a separate change.

Reviewer Test Plan

How to verify

cd packages/cli && npx vitest run src/ui/components/GoalPill.test.tsx src/ui/components/messages/GoalStatusMessage.test.tsx src/ui/opentui/live-session-model.test.ts src/nonInteractiveCli.test.ts

The new cases pin the exact rendered string at all four sites, and each site has a case for the with-budget, no-budget, and nothing-billed rules. formatGoalState additionally pins that the stop reason still reads below the usage line.

To see it live, set any Goal that runs a few turns and watch the footer.

Evidence (Before & After)

Before. Neither surface shows the pair; tokensUsed appears nowhere in either package's production code:

$ git grep -l tokensUsed -- packages/cli/src/ui packages/web-shell/client
packages/cli/src/ui/commands/goalCommand.test.ts
packages/cli/src/ui/components/GoalPill.test.tsx
packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
packages/cli/src/ui/components/messages/GoalStatusMessage.test.tsx
packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
packages/cli/src/ui/hooks/use-llm-stream.test.tsx
packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx
packages/web-shell/client/e2e/visuals/screenshots.spec.ts

Every hit is a test fixture. The footer read ◎ /goal active (19s) and the card read 1 turn · 21.7s.

After — footer pill, live TUI, climbing as the Goal works:

➜ work · qwen3.8-max · 1.0m Context 2.9% used          ◎︎ /goal active (19s · 87.2k/30.0m)
➜ work · qwen3.8-max · 1.0m Context 3% used           ◎︎ /goal active (28s · 147.0k/30.0m)

After — status card, live TUI:

✓︎ Goal complete · 1 turn · 21.7s · 80.7k/30.0m tokens
  Goal: Read a.txt, b.txt and c.txt with your file tools and quote the exact contents of each.
  Reason: The delivered output quotes the exact contents of all three files...

After — headless TEXT, resuming a session whose Goal had already run:

$ qwen -c -p '/goal'
Goal complete: Read a.txt, b.txt and c.txt with your file tools and quote the exact contents of each.
Usage: 1 turn · 63,214 of 30,000,000 tokens
Reason: All three files were read using the read_file tool (evidence f05eb286, cf501e22, 287fcc24)...

After — a Goal that has not billed a turn, which is what /goal set prints before the first turn runs:

$ qwen -p '/goal set Read a.txt and report its exact contents, then stop.'
Goal active: Read a.txt and report its exact contents, then stop.

No usage line, as intended.

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

Environment (optional)

Linux, npm run build, interactive TUI under tmux at 120x34 and headless --output-format text, live model.

Risk & Scope

  • Main risk or tradeoff: the pill grows by up to eleven characters in a footer that is already tight on narrow terminals. The nothing-billed rule keeps it off a fresh Goal, and the abbreviated form keeps it short, but on a very narrow terminal it is one more thing competing for the line.
  • Not validated / out of scope: Web Shell, which needs the SDK type first and is a separate change. The meter's own scope is unchanged; it still excludes subagents and the verifier's checks, and the user doc now says so.
  • Breaking changes / migration notes: none. Display only, reading two fields that were already on the record. formatGoalState becomes exported.

Linked Issues

Part of #4228.

中文说明

这个 PR 做了什么

现在每一个显示 Goal 的界面都会同时显示它在被允许的额度里已经花掉多少。底栏 pill 读作 ◎ /goal active (28s · 147.0k/30.0m),状态卡片的副标题多出一段 80.7k/30.0m tokens,headless 的状态输出多出一行 Usage:——TEXT 里有,而且因为那个字符串就是唯一的最终消息,--output-format jsonresult.summary 里同样有。

三条显示规则,四个位置完全一致。还没计过费的 Goal 什么数字都不显示:刚创建时的 0/30.0m 并没有说出状态本身没说过的东西,而 pill 待在底栏里也没有多余的地方去说它。没有预算的 Goal 只显示已花掉多少,因为那就是已知的全部。已经停下的 Goal 继续显示它的花费,因为一个 paused 或 blocked 的 Goal 花了多少,恰恰就是用户在决定要不要恢复时要看的。

pill 和卡片用状态栏已有的 formatTokenCount 做缩写。headless 那行则把数字完整写出来,因为那份输出是在终端回滚里读、被管道喂给脚本的,这两种场景都不会因为 1.2k 而变好。

有两处超出字面改动的地方。OpenTUI 的卡片用它自己的快照类型算自己的副标题,所以两边一起改了,而不是留着它们在一致性门禁下分叉。另外 formatGoalState 被导出,以便直接钉住它的输出:真正值得检查的那几个状态——跑到一半的 Goal、没有预算的 Goal、还没计过费的 Goal——把它们构造成快照,远比把一次 headless 运行驱动到那个状态便宜;而且这个文件本来就为同样的理由导出了 skipHeadlessLoopSentinel

为什么需要

这个额度窗口在执行,但是看不见。tokensUsedtokenBudget 从预算功能落地起就在 Goal 记录上了,30,000,000 的默认值会停下 Goal 并把控制权交还用户,而在此之前没有任何地方显示离那个上限还有多远。在 TUI 和 Web Shell 里 grep tokensUsed,返回的全是测试夹具,别的什么都没有。

于是用户只能看着轮数往上爬,无从分辨一个才用掉窗口百分之二的 Goal 和一个马上就要停的 Goal。Codex 在它的 TUI 状态栏里放的就是同一对数字,形如 63.9K/50K;这里做的是等价的事,放在本 CLI 已经用来说明 Goal 在干什么的那些位置上。

Web Shell 刻意不在本 PR 里。它的 strip 和 dialog 读的是手抄的 SDK GoalRecord,那份类型缺这两个字段,所以要先改类型,是单独的一次改动。

评审验证方式

如何验证

cd packages/cli && npx vitest run src/ui/components/GoalPill.test.tsx src/ui/components/messages/GoalStatusMessage.test.tsx src/ui/opentui/live-session-model.test.ts src/nonInteractiveCli.test.ts

新增用例在四个位置上分别钉死了渲染出来的确切字符串,每个位置都有"有预算""无预算""还没计费"三条规则各自的用例。formatGoalState 还额外钉住了停机原因仍然排在 usage 行下面。

要看实时效果,设一个会跑几轮的 Goal,然后看底栏。

证据(Before & After)

改动前。 两个界面都不显示这对数字;tokensUsed 在这两个包的生产代码里一处都没有:

$ git grep -l tokensUsed -- packages/cli/src/ui packages/web-shell/client
packages/cli/src/ui/commands/goalCommand.test.ts
packages/cli/src/ui/components/GoalPill.test.tsx
packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
packages/cli/src/ui/components/messages/GoalStatusMessage.test.tsx
packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
packages/cli/src/ui/hooks/use-llm-stream.test.tsx
packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx
packages/web-shell/client/e2e/visuals/screenshots.spec.ts

每一处命中都是测试夹具。底栏当时读作 ◎ /goal active (19s),卡片读作 1 turn · 21.7s

改动后 —— 底栏 pill,真实 TUI,随着 Goal 干活往上涨:

➜ work · qwen3.8-max · 1.0m Context 2.9% used          ◎︎ /goal active (19s · 87.2k/30.0m)
➜ work · qwen3.8-max · 1.0m Context 3% used           ◎︎ /goal active (28s · 147.0k/30.0m)

改动后 —— 状态卡片,真实 TUI:

✓︎ Goal complete · 1 turn · 21.7s · 80.7k/30.0m tokens
  Goal: Read a.txt, b.txt and c.txt with your file tools and quote the exact contents of each.
  Reason: The delivered output quotes the exact contents of all three files...

改动后 —— headless TEXT,续上一个 Goal 已经跑过轮的会话:

$ qwen -c -p '/goal'
Goal complete: Read a.txt, b.txt and c.txt with your file tools and quote the exact contents of each.
Usage: 1 turn · 63,214 of 30,000,000 tokens
Reason: All three files were read using the read_file tool (evidence f05eb286, cf501e22, 287fcc24)...

改动后 —— 还没计过费的 Goal,也就是 /goal set 在第一轮开始前打出来的东西:

$ qwen -p '/goal set Read a.txt and report its exact contents, then stop.'
Goal active: Read a.txt and report its exact contents, then stop.

没有 usage 行,符合预期。

测试平台

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

环境(可选)

Linux,npm run build,在 tmux 里以 120x34 跑交互式 TUI,以及 --output-format text 的 headless,均对真实模型。

风险与范围

  • 主要风险或权衡:pill 在本来就紧张的底栏里最多长出 11 个字符。"还没计费不显示"这条规则让它不会出现在刚创建的 Goal 上,缩写形式也让它保持简短,但在很窄的终端上,它确实是又一个在争这一行的东西。
  • 未验证 / 范围外:Web Shell,它需要先改 SDK 类型,是单独的一次改动。计量口径本身没有变,仍然不含子代理与 verifier 自己的检查,用户文档现在把这一点写明了。
  • 破坏性变更 / 迁移说明:没有。纯展示,读的是记录上本来就有的两个字段。formatGoalState 变成导出。

关联 Issue

属于 #4228 的一部分。

A Goal's autonomous spend window is enforced but invisible. `tokensUsed` and
`tokenBudget` have been on the record since budgets landed, the 30M default
stops the Goal and hands it back to the user, and nothing anywhere shows how
close it is to that. A `tokensUsed` grep across the TUI and Web Shell returned
only test fixtures. The user watched the turn count climb with no way to tell
a Goal two percent through its window from one about to stop.

The footer pill, the ink status card, its OpenTUI counterpart, and headless
TEXT status now report the spend. The pill and the cards abbreviate
(`1.2k/30.0m`) through the existing `formatTokenCount`; TEXT spells the
figures out, since that output is read in scrollback and piped into scripts,
neither of which is helped by `1.2k`.

Three display rules, the same at every site. A Goal that has not billed a turn
shows nothing: a fresh `0/30.0m` says nothing the status has not already said,
and the pill has little room to say it. A Goal with no budget shows what it
spent, which is the whole of what is known. A stopped Goal keeps showing its
spend, because what a paused or blocked Goal cost is exactly what the user is
deciding about.

The OpenTUI card computes its own subtitle from its own snapshot type, so both
were extended alongside the ink card rather than left to drift apart under the
parity gate. `formatGoalState` is exported to be pinned directly: the states
worth checking are far cheaper to construct as snapshots than to drive a
headless run into.

The figure is the Goal meter's own scope -- the model calls a Goal makes in
its own turns, excluding subagents and the verifier's checks -- and the user
doc says so.
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Deferred approval withheld — 1 PR CI workflow run(s) on a2ce97d did not finish green; see the updated table in the Stage 2 comment. Re-run @qwen-code /triage after fixes. finalize run

⚠️ 延迟审批已搁置 —— a2ce97d 有 1 个 PR CI workflow 未以绿色完成,详见 Stage 2 评论中已更新的表格。修复后可重新运行 @qwen-code /triage查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed, and I checked it rather than taking the description's word for it. Re-running your grep against the base tree returns exactly the nine files you list, every one a test fixture — tokensUsed has no production reader in either the TUI or the Web Shell. Meanwhile core really does enforce the window: isGoalTokenBudgetSpent trips the stop and goalTokenBudgetReason hands the Goal back to the user at the 30,000,000 default. So the bound is load-bearing and invisible, which is a genuine gap rather than a hypothetical one.

Direction: aligned. This is observability for a safety limit that already exists and already stops people's Goals — today the user experiences the ceiling only as "it stopped", with no way to see it coming. There's a comparable signal in the Claude Code CHANGELOG (a spend-limit bar in /usage plus a rate_limits.spend_limit status line field), and Codex's 63.9K/50K status pair as you cite. Deferring Web Shell checks out too: its hand-copied record has neither field anywhere in production code, so it does need the type change first.

Size: not applicable — nothing here touches core paths (all packages/cli plus one doc page). For reference the split is 71 production source lines, 231 test lines, 2 doc lines.

Approach: the scope feels right, and pinning the literal rendered string at all four sites is the part I most wanted to see. Three things worth a moment before the code review:

  • The spend-formatting block is verbatim identical in the ink card and the OpenTUI card, with a near-copy in the pill that differs only by dropping the tokens suffix. Both card files already import formatDuration from ui/utils/formatters.ts, so a formatGoalSpend living next to it would keep the three sites from drifting apart later. I can see the argument the other way — the turns/duration stats are already duplicated across that same ink/OpenTUI pair, so you may be following the local pattern deliberately. Either is fine; I'd just rather it be a choice than an accident.
  • The import you picked is load-bearing in a way the diff doesn't show. There are two exported formatTokenCount functions under ui/: the one in statusLinePresets.ts that you used has the m tier, while the same-named one in ui/utils/formatters.ts — the file both cards already import from — tops out at k and would render a 30,000,000 budget as 30000k. Your tests pin 30.0m, so a future "tidy the import" pass fails loudly instead of silently. Good outcome; just noting the trap is real.
  • The headless TEXT Usage: line also newly reports turn counts, which is a step past "show the spend". It's additive to the output and your description shows it, so no objection — just confirming it's intended rather than incidental.

Risk: no elevated risk signals — none of the changed files match the paths this repo's revert history correlates with. The narrow-footer tradeoff you flagged is real and correctly described: the footer's right section is flexShrink={0} and the pill's own Text carries no wrap="truncate", so the extra characters come out of the left column rather than wrapping the pill. Worth a maintainer's eye on an 80-column terminal; not a blocker.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:真实存在,而且我自己验证过,没有只采信 PR 描述。在 base 分支上重跑你的 grep,返回的正好是你列出的那 9 个文件,全部是测试夹具——tokensUsed 在 TUI 和 Web Shell 的生产代码里都没有读取方。而 core 确实在执行这个额度窗口:isGoalTokenBudgetSpent 触发停止,goalTokenBudgetReason 在默认的 30,000,000 处把 Goal 交还给用户。所以这个上限是真实生效但不可见的,属于实际缺口而不是假想问题。

方向:对齐。这是为一个已经存在、已经会停下用户 Goal 的安全上限补上可观测性——目前用户只能通过"它停了"来感知这个上限,无法看到它正在逼近。Claude Code 的 CHANGELOG 里有类似信号(/usage 的 Spend limit bar 加上 status line 的 rate_limits.spend_limit 字段),你也引用了 Codex 的 63.9K/50K。Web Shell 暂缓也说得通:它那份手抄的记录在生产代码里两个字段都没有,确实需要先改类型。

规模:不适用——没有触及核心路径(全部在 packages/cli 加一个文档页)。供参考的拆分是:生产代码 71 行、测试 231 行、文档 2 行。

方案:范围合理,四个位置都用测试钉住实际渲染字符串,这正是我最希望看到的部分。进入代码审查前有三点值得考虑:

  • 花费格式化那段代码在 ink 卡片和 OpenTUI 卡片里逐字相同,pill 里是一份只差去掉 tokens 后缀的近似副本。两个卡片文件本来就从 ui/utils/formatters.ts 引入 formatDuration,所以把 formatGoalSpend 放在它旁边,可以避免三处以后各自漂移。反方理由我也看得到——turns/duration 那部分统计在同一对 ink/OpenTUI 里本来就重复,所以你可能是有意沿用本地既有模式。两种都可以,我只希望这是一个选择,而不是无意造成的。
  • 你选的那个 import 有一处从 diff 上看不出来的关键性。ui/ 下有两个都导出的 formatTokenCount:你用的 statusLinePresets.ts 那个有 m 档;而同名的 ui/utils/formatters.ts(正是两个卡片已经在引入的文件)最高只到 k,会把 30,000,000 的预算渲染成 30000k。你的测试钉住了 30.0m,所以将来有人"顺手整理 import"时会直接测试失败,而不是静默变错。结果很好,只是提醒这个坑确实存在。
  • headless TEXT 的 Usage: 行还额外新增了轮数,这比"显示花费"多走了一步。它对输出是纯增量的,描述里也给了示例,所以没有异议——只是想确认这是有意为之而非顺带。

风险:无升级风险信号——改动文件都没有命中本仓库 revert 历史相关联的路径。你自己提出的窄终端权衡是真实存在且描述准确的:footer 右侧区域是 flexShrink={0},pill 自身的 Text 也没有 wrap="truncate",所以多出来的字符会挤压左栏,而不是让 pill 换行。建议维护者在 80 列终端上看一眼,但这不是阻塞项。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at a2ce97d2dc591113734189e7439faee5a6a6dddd · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Code review

I traced this end to end rather than reading the diff alone, because the interesting failure mode for a display change is data that never arrives at the surface.

The OpenTUI type additions are not decoration. event-adapter.ts pushes the goal snapshot through untouched (out.push({ type: 'goal', snapshot: v.value, cause }) — no field whitelist), and core's parseGoalRecord preserves both tokensUsed and tokenBudget across the journal, so describeGoalCard genuinely receives them. Worth stating because your tests construct snapshots directly and would pass just as happily if the fields were being dropped upstream.

The unlimited path lands where it should. model.goalTokenBudget: 0 or -1 becomes POSITIVE_INFINITY in normalizeGoalTokenBudget, and both createGoal and rearmedTokenBudget drop a non-finite budget ("unbounded is spelled as no field"). So tokenBudget === undefined is exactly the right predicate, and formatTokenCount(Infinity) — which would print a nonsense 0 as the denominator — is unreachable.

The metering claim in the new doc sentence is accurate. tokensUsed is billed only from recordAssistantTurn({ goalContext }), and goalContext is an explicit parameter threaded from the Goal turn through llm-chat.ts; no subagent or agent-runner file passes it, and core's own comment states checkpoint-verifier calls are unmetered. "Subagents and the verifier's own checks are not included" is true as written, not just plausible.

No existing CLI test can move. The new rule only fires on data no current fixture carries: every tokensUsed in packages/cli test fixtures is 0 (including the HistoryItemDisplay lifecycle-card case), and no fixture in nonInteractiveCli.test.ts has a nonzero turnCount, so all seven existing Goal …: expectations stay byte-identical. The only nonzero fixtures anywhere are two in packages/web-shell, which this PR doesn't touch and whose production code doesn't read the fields.

Two things I'd want an answer on. Neither blocks the merge:

  1. A resumed Goal's denominator is cumulative, and the doc doesn't say so. rearmedTokenBudget sets tokenBudget = tokensUsed + grant when a spent Goal is resumed, so immediately after /goal resume every surface reads 30.0m/60.0m. I think that is the right number — it mirrors the actual stop predicate (isGoalTokenBudgetSpenttokensUsed >= tokenBudget) exactly, and a fresh-looking 0/30.0m would misrepresent when the Goal is going to stop. But the sentence you added to goals.md says "The window is set by model.goalTokenBudget", which holds only for the first window. One clause — after a resume the window is what it has spent plus model.goalTokenBudget — would make the doc match the pill and spare someone the "why am I already half spent?" double take.

  2. The headless line isn't TEXT-only. formatGoalState's return value is the single message handed to emitNonInteractiveFinalMessage, which feeds it to the JSON adapter as assistant content and as result.summary, and separately to process.stdout.write on the worker path. So --output-format json consumers reading result.summary after a /goal control get the Usage: line as well. Purely additive — nothing renamed or removed, and headless.md pins no example output that goes stale — but the description frames this as "headless TEXT status gains a Usage: line", and it's worth saying plainly that the JSON summary changes with it.

Smaller notes, take them or leave them:

  • The spend block in the ink card and the OpenTUI card is byte-identical, and the pill's differs only by dropping the tokens suffix. ui/utils/formatters.ts already hosts the formatDuration both cards import, so a formatGoalSpend beside it would keep three sites from drifting. I see the turns/duration stats are already duplicated across that same pair, so this may well be deliberate — following the local pattern is defensible.
  • The formatter you picked is load-bearing in a way the diff doesn't show: the same-named export in ui/utils/formatters.ts has no m tier and would render the default budget as 30000k. Your pinned 30.0m expectations mean a well-intentioned import tidy fails loudly rather than silently. Good outcome, real trap.
  • GoalStatusMessage.test.tsx's no-budget case asserts not.toContain('/') against the whole frame, which is broader than the thing it pins — a / anywhere in the objective or reason would fail it for the wrong reason. Today's fixture has none, so it works.

Testing

Unattended CI run: I executed nothing from this PR (no build, no test, no checkout). The evidence below is this PR's own CI, read through the API for the reviewed commit, plus the static verification described above.

Green and relevant: Lint & Static, TUI parity snapshots (ink vs opentui), OpenTUI no-flicker gate, both Desktop Shell jobs and precheck all completed success. The parity gate passing matters here specifically — it renders the same fragment under ink and OpenTUI and diffs layout/text, and this PR changes a card subtitle on both sides. Still running at the time of writing: Test (ubuntu-latest, Node 22.x) — the suite that actually runs your new cases — and Integration Tests (no-AK, No Sandbox). I'm reporting them as pending rather than guessing; the finalize job rewrites the table below once they settle. Test (macos-latest) and Test (windows-latest) are skipped on this PR, so combined with your macOS/Windows ⚠️ rows the only platform evidence anywhere is Linux. For string formatting that is a low-risk gap, but it is a gap.

Not verified, and why: the rendered footer at narrow width. Your captures are at 120x34; the risk you flag yourself is the tight footer, and neither the diff nor the unit suite can show what 80 columns does to it — the right section is flexShrink={0} and the pill's Text carries no wrap="truncate", so the left column absorbs the growth and truncates.

Final CI results for a2ce97d (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Integration Tests (no-AK, No Sandbox) ❌ failure
Test (ubuntu-latest, Node 22.x) ❌ failure
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ❌ failure
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
OpenTUI no-flicker gate ✅ success
TUI parity snapshots (ink vs opentui) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

(Bot orchestration checks — Classify PR, assign, label, authorize, delay-automatic-review, ack-review-request — are success/skipped and say nothing about the change; triage and review-pr in progress are this review.)

Sandboxed verification would settle the one open claim: @qwen-code /tmux — whether the grown pill keeps the footer on a single line at 80 columns, and whether the figure actually climbs as a Goal bills turns in a live session. Neither is observable from the diff, and the unit suite pins the formatted string but not the layout that contains it. You have write access, so the job is available to trigger directly.

中文说明

代码审查

我没有只读 diff,而是把链路走了一遍——因为展示类改动真正有意思的失败模式,是数据根本没到达展示面。

OpenTUI 那两处类型新增不是摆设。 event-adapter.ts 把 goal 快照原样传下去(out.push({ type: 'goal', snapshot: v.value, cause }),没有字段白名单),core 的 parseGoalRecord 也会在 journal 往返中保留 tokensUsedtokenBudget,所以 describeGoalCard 确实能拿到这两个字段。之所以要专门说明:你的测试直接构造快照,即使上游把字段丢了,这些测试一样会通过。

"无上限"这条路走对了位置。 model.goalTokenBudget: 0-1normalizeGoalTokenBudget 里变成 POSITIVE_INFINITY,而 createGoalrearmedTokenBudget 都会丢掉非有限的预算("unbounded is spelled as no field")。所以 tokenBudget === undefined 正是对的判据,而 formatTokenCount(Infinity)(会把分母渲染成荒谬的 0)根本不可达。

新文档句子里关于计量范围的说法是准确的。 tokensUsed 只在 recordAssistantTurn({ goalContext }) 里计费,而 goalContext 是从 Goal 回合经 llm-chat.ts 显式传参下来的;没有任何 subagent 或 agent-runner 文件传它,core 自己的注释也写明 checkpoint-verifier 的调用不计量。所以"不包含 subagent 和 verifier 自身的检查"是事实陈述,不只是听起来合理。

现有 CLI 测试不会被改动。 新规则只会在当前夹具不具备的数据上触发:packages/cli 测试夹具里的 tokensUsed 全是 0(包括 HistoryItemDisplay 的 lifecycle-card 用例),nonInteractiveCli.test.ts 里也没有任何夹具的 turnCount 非零,所以已有的 7 处 Goal …: 断言逐字不变。全仓库唯一的非零夹具是 packages/web-shell 里的两处,本 PR 没有碰它,其生产代码也不读这两个字段。

有两点我希望得到答复,但都不阻塞合并:

  1. resume 之后分母是累计的,而文档没有说明。 rearmedTokenBudget 在恢复一个已花完的 Goal 时会把 tokenBudget 设为 tokensUsed + grant,所以 /goal resume 之后所有界面立刻读作 30.0m/60.0m。我认为这是对的数字——它精确对应真正的停止判据(isGoalTokenBudgetSpenttokensUsed >= tokenBudget),而显示成崭新的 0/30.0m 反而会误导用户 Goal 何时会停。但你在 goals.md 里加的那句"窗口由 model.goalTokenBudget 设定"只对第一个窗口成立。补一个从句——resume 之后的窗口是已花费量加上 model.goalTokenBudget——就能让文档和 pill 显示一致,也省掉用户"怎么我一上来就花掉一半"的困惑。

  2. headless 那行不只是 TEXT。 formatGoalState 的返回值是交给 emitNonInteractiveFinalMessage 的那个唯一 message,它既作为 assistant content 和 result.summary 进入 JSON adapter,也在 worker 路径上单独 process.stdout.write。所以 --output-format json 的使用者在 /goal 控制之后读 result.summary 时,同样会拿到这行 Usage:。纯属增量——没有重命名或删除任何字段,headless.md 里也没有会因此过期的示例输出——但描述里写的是"headless TEXT status 多出一行 Usage:",把 JSON summary 也一起变了这件事值得说清楚。

一些小点,采纳与否都可以:

  • ink 卡片和 OpenTUI 卡片里的花费代码块逐字相同,pill 里的只差去掉 tokens 后缀。ui/utils/formatters.ts 已经放着两个卡片都在引入的 formatDuration,所以在它旁边加一个 formatGoalSpend 可以避免三处以后漂移。我也看到 turns/duration 统计在同一对实现里本来就是重复的,所以这很可能是有意为之——沿用本地模式是站得住的。
  • 你选的 formatter 有一处 diff 上看不出的关键性:ui/utils/formatters.ts 里同名的导出没有 m 档,会把默认预算渲染成 30000k。你钉住 30.0m 的断言意味着将来有人好心整理 import 时会直接测试失败,而不是静默变错。结果很好,坑也确实存在。
  • GoalStatusMessage.test.tsx 里"无预算"那个用例对整个渲染结果断言 not.toContain('/'),范围比它想钉住的东西宽——objective 或 reason 里出现任何 / 都会让它因为错误的原因失败。当前夹具里没有,所以能正常工作。

测试

无人值守 CI 运行:我没有执行本 PR 的任何代码(没有构建、没有跑测试、没有 checkout)。下面的证据来自通过 API 读取的、针对被审 commit 的本 PR 自身 CI,加上上文所述的静态验证。

已绿且相关:Lint & StaticTUI parity snapshots (ink vs opentui)OpenTUI no-flicker gate、两个 Desktop Shell 作业以及 precheck 全部 success。这里 parity 门禁通过尤其有意义——它把同一个 fragment 分别在 ink 和 OpenTUI 下渲染并比对布局/文本,而本 PR 在两侧都改了卡片副标题。写入本条评论时仍在运行:Test (ubuntu-latest, Node 22.x)(真正执行你新增用例的那个套件)和 Integration Tests (no-AK, No Sandbox)。我如实标为 pending 而不是猜测结果;finalize 作业会在它们结束后就地更新下面的表格。Test (macos-latest)Test (windows-latest) 在本 PR 上是 skipped,再加上你 macOS/Windows 的 ⚠️,所有平台证据只有 Linux。对字符串格式化来说这个缺口风险不高,但确实是个缺口。

未验证及其原因:窄终端下的实际 footer 渲染。你的截图是 120x34;你自己标出的风险恰恰是拥挤的 footer,而 diff 和单测都无法说明 80 列时会怎样——右侧区域是 flexShrink={0},pill 的 Text 没有 wrap="truncate",所以增长会由左栏吸收并被截断。

(机器人编排类检查——Classify PR、assign、label、authorize、delay-automatic-review、ack-review-request——为 success/skipped,与改动质量无关;进行中的 triagereview-pr 就是本次审查。)

沙箱验证可以解决唯一悬着的那个论断:@qwen-code /tmux——变长的 pill 在 80 列下是否仍能让 footer 保持单行,以及 Goal 计费时数字是否在真实会话里递增。这两点都无法从 diff 看出来,单测钉住的是格式化后的字符串,而不是容纳它的布局。你有 write 权限,可以直接触发这个作业。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at a2ce97d2dc591113734189e7439faee5a6a6dddd · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — a clean, well-evidenced display change; what's left is a doc clause and a framing note, not correctness.

Before reading the diff I wrote down what I'd do: the same four surfaces, reuse the abbreviation helper the status line already has rather than inventing a formatter, say nothing until a turn has billed, show spend alone when there's no ceiling, and keep the OpenTUI mirror in step so the parity gate doesn't drift. The PR does all of that, and beats my baseline in one place I hadn't thought of — it splits the format by audience, abbreviated in the TUI and spelled out with digit grouping in headless, because that output gets piped into scripts and read in scrollback. I'd have used one formatter everywhere and handed a script consumer 1.2k to parse. Better call than mine.

What makes me comfortable saying yes is that the load-bearing claims checked out when I tested them instead of accepting them: your base-tree grep reproduced file for file, Infinity genuinely cannot reach the denominator so the unbounded setting renders as spend-only, no existing CLI fixture carries nonzero token data so nothing current can regress, the doc's metering sentence matches how goalContext is actually threaded, and the parity gate — which renders both card implementations and diffs them — is green on a PR that changes a card subtitle on both sides.

Everything in the diff is in service of the stated goal. No drive-by refactors, no formatting churn, and the formatGoalState export exists so the states can be pinned directly, following the skipHeadlessLoopSentinel precedent already in that file. Seventy-one production lines against 231 test lines is the right ratio for a change whose entire substance is "what string appears".

What I could not settle, and am not pretending otherwise: the live footer at narrow width. That's your own flagged risk, the unit suite pins the string but not the layout containing it, and @qwen-code /tmux is the thing that would answer it. I'd rather a maintainer see that gap named than see it papered over.

If I pick this up in six months the only way it annoys me is if the three copies of the spend rule drift apart — which is why I named the shared-helper option in Stage 2, and why it isn't a blocker: the ink/OpenTUI pair already duplicates its turns and duration stats the same way, and literal-string tests on both sides catch a divergence loudly.

On pattern: you have seven PRs open and #11254 is the Web Shell sibling of this one, so "Web Shell needs the SDK type first and is a separate change" is a real split with the follow-up already filed rather than a way to trim scope. Judged on its own merits, this one earns it.

Verdict: approve — deferred until CI lands green on a2ce97d2dc591113734189e7439faee5a6a6dddd. Test (ubuntu-latest, Node 22.x) — the suite that runs your new cases — and Integration Tests (no-AK, No Sandbox) were still in progress when I wrote this, so I'm posting no approval in this run; the marker below carries the commit-pinned approval once every check on that SHA completes green, and withholds it if anything lands red or the head moves. The two Stage 2 questions (the cumulative window after a resume, and the JSON result.summary changing alongside TEXT) are answers I'd like in the thread, not conditions on the merge.

中文说明

Confidence: 4/5 —— 干净、证据充分的展示类改动;剩下的是一条文档从句和一个表述口径问题,不是正确性问题。

读 diff 之前我先写下了自己会怎么做:同样那四个界面、复用 status line 已有的缩写工具而不是另造一个 formatter、在计费之前什么都不显示、没有上限时只显示花费、并让 OpenTUI 那份镜像保持同步以免 parity 门禁漂移。这些本 PR 都做了,而且有一处比我的方案更好,是我没想到的——它按受众区分了格式:TUI 里缩写,headless 里带千分位完整写出,因为那份输出会被管道喂给脚本、也会在终端回滚里读。我大概会到处用同一个 formatter,然后丢给脚本使用者一个 1.2k 去解析。这个判断比我强。

让我能放心说 yes 的原因是:那些关键论断我是自己去验的,而不是照单接受——你的 base 分支 grep 我逐文件复现了;Infinity 确实到不了分母,所以无上限设置会渲染成"只显示花费";现有 CLI 夹具里没有非零的 token 数据,所以当前没有任何断言会被改动;文档里那句计量范围的说法与 goalContext 实际的传参方式一致;而 parity 门禁(它会把两份卡片实现分别渲染后比对)在一个两侧都改了卡片副标题的 PR 上是绿的。

diff 里每一处都服务于既定目标。没有顺手重构,没有格式化噪声,而 formatGoalState 的导出是为了能直接钉住那些状态,沿用了同一文件里 skipHeadlessLoopSentinel 的既有先例。71 行生产代码对 231 行测试,对一个全部内容就是"屏幕上出现什么字符串"的改动来说,比例是对的。

我没能确定、也不打算假装确定的:窄终端下真实 footer 的布局。那是你自己标出的风险,单测钉住的是字符串而不是容纳它的布局,而能回答这个问题的是 @qwen-code /tmux。我宁可把这个缺口点出来给维护者看,也不想把它糊过去。

如果六个月后我再来维护这段代码,唯一会让我皱眉的是那三处花费规则各自漂移——这也是我在 Stage 2 里点名共享 helper 这个选项的原因,而它之所以不是阻塞项:ink/OpenTUI 那对实现本来就用同样方式重复了 turns 和 duration 统计,而且两侧的字符串断言会让任何分叉直接报错。

关于"是否是一批 PR 中的一个":你有 7 个 PR 处于 open 状态,而 #11254 正是本 PR 的 Web Shell 姊妹篇,所以"Web Shell 需要先改 SDK 类型,是独立改动"是一个真实的拆分、后续 PR 已经提交,而不是为了缩小范围的说辞。就本 PR 自身来看,它站得住。

结论:批准——但推迟到 CI 在 a2ce97d2dc591113734189e7439faee5a6a6dddd 上全绿。 我写这段时,Test (ubuntu-latest, Node 22.x)(真正跑你新增用例的套件)和 Integration Tests (no-AK, No Sandbox) 仍在进行中,所以本次运行不提交批准;下方标记会在该 SHA 上所有检查全绿后提交绑定到该 commit 的批准,若有检查变红或 head 移动则不予批准。Stage 2 的两个问题(resume 之后的累计窗口,以及 JSON 的 result.summary 随 TEXT 一起变化)是我希望在讨论串里看到答复的问题,不是合并的前置条件。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at a2ce97d2dc591113734189e7439faee5a6a6dddd · re-run with @qwen-code /triage

…resetting

Resuming sets the budget to what the Goal has spent plus another grant,
so the pill reads 30.0m/60.0m straight after a resume. The paragraph
said the window is what the setting says, which holds only for the
first one.

Also narrow the no-budget card assertion, which read as "no slash
anywhere in the frame" and would have failed on a slash in the
objective.
@qqqys

qqqys commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Both questions answered, and both turned out to be worth a change.

1. The resumed denominator is cumulative, and it should be. rearmedTokenBudget sets tokenBudget = tokensUsed + grant, which is exactly the number isGoalTokenBudgetSpent compares against, so 30.0m/60.0m after a resume is telling the truth about when the Goal will stop next. A reset-looking 0/30.0m would not. You are right that the doc did not say so, and someone reading model.goalTokenBudget would have been surprised by their own pill. The paragraph in goals.md now carries the clause.

2. The headless line is not TEXT-only. Correct, and the description was wrong to imply otherwise: formatGoalState's return value is the single final message, so result.summary under --output-format json gains the Usage: line with it. Purely additive, but it is a JSON-shaped surface and the description now says so in both halves.

Also took the note on the no-budget card assertion. not.toContain('/') was pinning "no slash anywhere in the frame", which a slash in the objective would have broken for the wrong reason; it now pins not.toContain('1.2k/'), which is the thing meant.

Left the shared formatGoalSpend alone. The turns and duration stats are already duplicated across the same ink/OpenTUI pair, and the parity gate is what actually holds the two together; extracting one of the three stats would leave a half-shared card. Worth doing as its own change if the pair grows again.

On the red checks, none of which are this change:

Happy to have the first two rerun.

中文说明

两个问题都答,而且都确实带来了改动。

1. resume 之后的分母是累计的,而且应该是。 rearmedTokenBudgettokenBudget 设为 tokensUsed + grant,这正是 isGoalTokenBudgetSpent 拿来比较的那个数,所以 resume 之后显示 30.0m/60.0m 是在如实说明 Goal 下一次会在哪里停。显示成崭新的 0/30.0m 反而不是。文档确实没说这件事,一个读了 model.goalTokenBudget 的人会被自己的 pill 吓一跳。goals.md 里那段已经补上这个从句。

2. headless 那行不只是 TEXT。 说得对,描述那样写是错的:formatGoalState 的返回值就是唯一的最终消息,所以 --output-format jsonresult.summary 也会跟着多出这行 Usage:。纯属增量,但那是一个 JSON 形状的界面,描述现在中英两边都写明了。

卡片"无预算"用例的那条建议也采纳了。not.toContain('/') 钉的是"整帧里没有斜杠",目标文本里有个斜杠就会因为错误的原因失败;现在钉的是 not.toContain('1.2k/'),也就是本来想钉的东西。

共享的 formatGoalSpend 没有做。轮数和时长这两个统计本来就在同一对 ink/OpenTUI 卡片里各写了一份,真正把两边拴在一起的是一致性门禁;只把三个统计里的一个抽出去,会留下一张抽了一半的卡片。等这一对再长的时候,值得单独开一个改动来做。

关于几个红的检查,没有一个是这次改动引起的:

  • Test (ubuntu)Test Files 1015 passedTests 28874 passed | 90 skipped,然后两个 [vitest-worker]: Timeout calling "onTaskUpdate" 未捕获错误把这次运行判成失败。那段时间的 DFSAMPLE 行显示宿主负载在 118-130。没有任何断言失败。
  • Integration Tests (no-AK):跑了 49 分钟后在 job 上限被杀,日志末尾是中途被终止的孤儿进程。全文没有任何
  • web-shell E2E Smoke:bounds/ordering 那几个 smoke spec 有 12 个 Playwright 失败。本 PR 没有碰任何 Web Shell 文件,而同一个 job 在无关的开放 PR 上(fix(serve): report child-owned session turns as active work #11265feat(goal): carry budget figures and progress guidance in the continuation prompt #11257)以同样方式失败。看起来它本身就是坏的。

前两个重跑即可。

@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.

Reviewed — no blockers. Suggestions are inline.

Test Plan (not a blocker): 87.2k/30.0mno such file or directory; 147.0k/30.0mno such file or directory; 80.7k/30.0mno such file or directory.

中文说明

已审查——无阻断问题。 建议见行内评论。

Test Plan(非阻断):87.2k/30.0mno such file or directory; 147.0k/30.0mno such file or directory; 80.7k/30.0mno such file or directory

— qwen3.8-max via Qwen Code /review (v0.23.0)


Creating, editing, or resuming a Goal requires a trusted workspace (`/trust`). Headless usage is covered in [Headless Mode](./headless.md#run-a-persistent-goal).

Once a Goal has billed a turn, the footer pill and every status card show what it has spent against the window it is allowed, as `1.2k/30.0m`. The figure counts the model calls the Goal makes in its own turns; subagents and the verifier's own checks are not included. The window is set by [`model.goalTokenBudget`](../configuration/settings.md); resuming a Goal that has spent its window grants another one on top of what it has already spent, so the figure reads `30.0m/60.0m` rather than starting over. A Goal with no budget shows only what it has spent. A Goal that has not billed a turn yet shows no figures at all.

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 sentence this diff adds says the footer pill and every status card show the spend, but two families of Goal status card render no figure.

Every Web Shell Goal surface is one. The daemon payload does carry the numbers, but packages/web-shell/client/daemon/session/mappers.ts:596-691 rebuilds the goal record from an explicit whitelist that omits tokensUsed/tokenBudget, and the hand-copied SDK mirror at packages/sdk-typescript/src/daemon/types.ts:50-62 never declared them — so GoalStatusStrip, GoalsDialog and the web-shell GoalStatusMessage show status, turns and elapsed only. The legacy goal_status card is the other, in ink and OpenTUI alike: projectGoalStateToLegacy (packages/core/src/goals/goal-legacy-projection.ts:56-72) emits only iterations/setAt/durationMs/lastReason, and it is live rather than historical — called from acp-integration/session/emitters/MessageEmitter.ts:57, …/history-replayer.ts:138 and acp-bridge/src/transcript-replay.ts:995. Your own new test leaves the legacy card without spend it cannot know pins that half.

This page treats Web Shell as a Goal surface elsewhere and scopes features away from it explicitly ("nor yet in Web Shell or other ACP-driven sessions"), so an unscoped "every" reads as a claim about coverage rather than an oversight. A user who follows it into the Goals dialog finds 4 turns and an elapsed runtime and no figure, and the paragraph added here is the only place the readout is described. This is about the accuracy of the sentence, not a request to implement Web Shell in this PR — deferring that to a separate change is untouched by the fix.

Scoping the sentence to the surfaces this diff changed is enough, and matches the page's own precedent:

Once a Goal has billed a turn, the footer pill and the CLI Goal status cards
show what it has spent against the window it is allowed, as `1.2k/30.0m`.

Witness:

probe over the real, unmodified web-shell mapper (packages/web-shell), run in a scratch tree:
PROBE_R1_1_INCOMING: {"goalId":"goal-1",...,"turnCount":3,"activeTimeMs":4000,"tokensUsed":4500,"tokenBudget":30000000,...}
PROBE_R1_1_MAPPED:   {"goalId":"goal-1",...,"turnCount":3,"activeTimeMs":4000,"createdAt":10,"updatedAt":20}
PROBE_R1_1_KEYS: ["activeTimeMs","createdAt","evidenceCursor","goalId","objective","revision","status","turnCount","updatedAt"]
PROBE_R1_1_hasTokensUsed: false  hasTokenBudget: false

If you take the carry-through route instead, mappers.ts:596-691 returns undefined for the whole snapshot unless every required field is present, so the two fields have to go in as optional spreads rather than into the required-field guard — otherwise every pre-tokensUsed daemon snapshot fails the guard and the goal card disappears; and sdk-typescript/src/daemon/types.ts:75-81 states the mirror is hand-duplicated and must match packages/core/src/goals/goal-protocol.ts, where tokensUsed: number is required (:166) and tokenBudget?: number optional (:174).

中文说明

本 diff 新增的那句话说底栏 pill 和每一个状态卡片都会显示花费,但有两类 Goal 状态卡片根本不渲染这个数字。

Web Shell 的所有 Goal 界面是其中一类。daemon 的载荷确实带着这两个数字,但 packages/web-shell/client/daemon/session/mappers.ts:596-691 是按一份显式白名单重建 goal 记录的,白名单里没有 tokensUsed/tokenBudget,而手抄的 SDK 镜像类型 packages/sdk-typescript/src/daemon/types.ts:50-62 也从没声明过它们——所以 GoalStatusStripGoalsDialog 和 web-shell 的 GoalStatusMessage 只显示状态、轮数和耗时。另一类是 legacy goal_status 卡片,ink 和 OpenTUI 两边都是:projectGoalStateToLegacypackages/core/src/goals/goal-legacy-projection.ts:56-72)只发出 iterations/setAt/durationMs/lastReason,而且它是活的、不是历史遗留——调用方有 acp-integration/session/emitters/MessageEmitter.ts:57…/history-replayer.ts:138acp-bridge/src/transcript-replay.ts:995。你自己新增的用例 leaves the legacy card without spend it cannot know 钉住的正是这一半。

这个文档页在别处是把 Web Shell 当作 Goal 界面来谈的,并且会明确地把功能范围排除出去("nor yet in Web Shell or other ACP-driven sessions"),所以没有限定范围的 "every" 读起来就是一个关于覆盖面的断言,而不是一时疏忽。用户照着这句话打开 Goals dialog,看到的是 4 turns 和一段运行时长,没有花费数字;而这里新增的段落又是整个读出唯一的说明位置。这一条说的是这句话准不准,不是要求本 PR 去实现 Web Shell——把那件事拆成单独一次改动的决定不受这个修复影响。

把这句话限定到本 diff 真正改过的那些界面就够了,而且这与本页已有的写法一致(建议措辞见上方代码块)。

如果改走"把字段带过去"这条路:mappers.ts:596-691 在缺少任一必填字段时会对整个快照返回 undefined,所以这两个字段必须以可选展开的方式加入,而不能进必填字段的校验里——否则所有早于 tokensUsed 的 daemon 快照都会通不过校验,整张 goal 卡片直接消失;另外 sdk-typescript/src/daemon/types.ts:75-81 写明这份镜像是手抄的、必须与 packages/core/src/goals/goal-protocol.ts 一致,那里 tokensUsed: number 是必填(:166)、tokenBudget?: number 是可选(:174)。

— qwen3.8-max via Qwen Code /review (v0.23.0)

/**
* The Goal's spend against the window it is allowed, or nothing.
*
* A Goal that has not billed a turn yet shows no figures rather than a

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 rule is stated as a turn gate — here, in docs/users/features/goals.md:19 ("Once a Goal has billed a turn"), and in three of the new test titles — but all four implementations gate on recorded spend (tokensUsed > 0 / <= 0), and the two diverge on state that is reachable in production.

A Goal can bill turns and record nothing. llm-chat.ts:5222 declares usageMetadata optional and sets it only from chunk.usageMetadata; chatRecordingService.ts:2121-2124 accumulates only if (data.tokens), and accumulateGoalTurnTokens (:2062-2075) early-returns unless totalTokenCount is finite and greater than zero; meanwhile goal-reducer.ts:232-233 still does turnCount: current.turnCount + 1, tokensUsed: current.tokensUsed + Math.max(0, transition.tokensUsed ?? 0). Render that Goal on all four surfaces and no figure appears anywhere — while the doc promises one "once a Goal has billed a turn". Because this readout is the only user-visible signal that token metering works at all, its absence is indistinguishable from "has not billed a turn yet", and the same Goal never approaches its budget stop: someone debugging "why did this Goal run for six hours" gets no hint that spend was never being counted. The state is documented in-repo too — goal-protocol.ts:160-166 says tokensUsed is "Zero on Goals recovered from a transcript written before the field existed", implemented at goal-reducer.ts:625-626.

The tokensUsed gate itself is right and should stay. What is wrong is the prose describing it, which sends a maintainer to the wrong predicate: reading this comment and the doc, they would conclude the gate is turnCount and either hunt the wrong code or "fix" the pill to gate on turns, reintroducing exactly the 0/30.0m this comment rejects.

Stating the gate as spend in the three places it is claimed closes it — this JSDoc ("A Goal with no recorded spend shows no figures rather than a reassuring 0/30.0m…"), goals.md:19 ("Once a Goal has recorded token spend…"), and the test titles at GoalPill.test.tsx:163, GoalStatusMessage.test.tsx:177, live-session-model.test.ts:687. One clause in goals.md noting that the figure is absent when the model responses carry no token usage would also close it for the operator debugging a Goal that never nears its ceiling.

Witness:

probe rendering/calling all four surfaces on the unmodified PR code,
Goal = { turnCount: 3, tokensUsed: 0, tokenBudget: 30_000_000 }  <- HAS billed three turns
PROBE_R1_2_PILL:         "◎︎ /goal active (5s)"
PROBE_R1_2_INK_CARD:     "◎︎ Goal running · 3 turns · 2s\n Goal: finish the refactor"
PROBE_R1_2_OPENTUI_CARD: {..."subtitle":"3 turns · 2s"...}
PROBE_R1_2_HEADLESS:     "Goal active: finish the refactor\nUsage: 3 turns"

The display gate must stay tokensUsed and must not become turnCount while rewording: packages/core/src/goals/goal-protocol.ts:166 documents tokensUsed as "Zero on Goals recovered from a transcript written before the field existed", and goal-reducer.ts:232 increments turnCount in the same reduction that may add zero tokens, so a turn gate would render 0/30.0m for exactly those Goals.

中文说明

这条规则被表述成一个"轮次"闸门——在这里、在 docs/users/features/goals.md:19("Once a Goal has billed a turn")、以及三个新增用例的标题里——但四处实现判断的都是已记录的花费tokensUsed > 0 / <= 0),而这两者在生产中可达的状态上会分叉。

一个 Goal 可以计了轮次却什么都没记下。llm-chat.ts:5222usageMetadata 声明为可选,只在 chunk.usageMetadata 出现时赋值;chatRecordingService.ts:2121-2124 只在 if (data.tokens) 时累加,而 accumulateGoalTurnTokens(:2062-2075)在 totalTokenCount 不是有限的正数时直接提前返回;与此同时 goal-reducer.ts:232-233 仍然执行 turnCount: current.turnCount + 1, tokensUsed: current.tokensUsed + Math.max(0, transition.tokensUsed ?? 0)。把这样一个 Goal 渲染到四个界面上,任何一处都不会出现数字——而文档承诺的是"一旦 Goal 计过一轮"就有数字。因为这个读出是用户唯一能看到的"token 计量在工作"的信号,它的缺失与"还没计过轮次"完全无法区分;同一个 Goal 也永远不会逼近自己的预算停止点:有人在排查"这个 Goal 为什么跑了六小时"时,得不到任何关于花费从未被计入的提示。这个状态在仓库里也是有文档的——goal-protocol.ts:160-166 写明 tokensUsed 在"从早于该字段的 transcript 恢复出来的 Goal 上为零",实现在 goal-reducer.ts:625-626

tokensUsed 这个闸门本身是对的,应该保留。错的是描述它的文字,它会把维护者引向错误的判据:读了这段注释和文档,他们会以为闸门是 turnCount,于是要么去查错的代码,要么"修好" pill 让它按轮次判断——那就正好把这段注释想要避免的 0/30.0m 又装了回来。

在声称这条规则的三个地方把闸门改述为"花费"就能收口——本 JSDoc("A Goal with no recorded spend shows no figures rather than a reassuring 0/30.0m…")、goals.md:19("Once a Goal has recorded token spend…")、以及 GoalPill.test.tsx:163GoalStatusMessage.test.tsx:177live-session-model.test.ts:687 三处用例标题。在 goals.md 里再加一个从句,说明模型响应不带 token usage 时这个数字不会出现,也能为那位排查"Goal 永远不接近上限"的使用者把话讲完。

改述时,显示闸门必须继续是 tokensUsed,不能变成 turnCountpackages/core/src/goals/goal-protocol.ts:166 写明 tokensUsed 在"从早于该字段的 transcript 恢复出来的 Goal 上为零",而 goal-reducer.ts:232 是在同一次 reduce 里递增 turnCount、却可能加进 0 个 token,所以按轮次判断恰恰会对这些 Goal 渲染出 0/30.0m

— qwen3.8-max via Qwen Code /review (v0.23.0)

const elapsed = formatElapsed(elapsedActiveTime(goal, Date.now()));
const suffix = elapsed ? ` (${elapsed})` : '';
const spend = formatSpend(goal);
const detail = [elapsed, spend].filter(Boolean).join(' · ');

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 new two-segment detail is tested in three of its four shapes. The one where spend is non-empty while elapsed is '' has no test, because every pill fixture inherits a non-empty elapsed — and a plausible rewrite of this line that breaks exactly that shape survives the whole suite.

GoalPill.test.tsx's snapshot() helper hard-codes activeTimeMs: 2_000 and updatedAt: 7_000 against NOW = 10_000 (:23, :40-44), and none of the four new tests overrides either, so formatElapsed never returns '' in any of them and detail always has its 5s/2s head. The uncovered shape is reachable, not hypothetical: formatElapsed returns '' below 1000 ms (GoalPill.tsx:36-37), and transitionGoal sets activeTimeMs = elapsedActiveTime(goal, now) (goal-reducer.ts:511-521) in the very transition reduceGoalTurnFinished uses to add the turn's spend (:221-241) — so a Goal paused, blocked or usage-limited with under a second of accumulated active time and a billed turn renders spend with no elapsed permanently, and the active form is the same state inside its first second. Rewrite the join as const detail = spend ? [elapsed, spend].join(' · ') : elapsed and that Goal's footer reads ◎ /goal paused ( · 1.2k/30.0m) — a leading separator with nothing before it — with all 15 of the pill's tests still green.

One case covering the shape closes it:

it('shows spend alone when no elapsed second has accrued', () => {
  vi.setSystemTime(NOW);
  const { lastFrame, unmount } = renderPill({
    snapshot: snapshot('paused', 'handoff', {
      activeTimeMs: 400,
      updatedAt: NOW,
      tokensUsed: 1_234,
      tokenBudget: 30_000_000,
    }),
  });

  expect(lastFrame()).toContain('(1.2k/30.0m)');
  expect(lastFrame()).not.toContain('( ·');
  unmount();
});

Witness:

mutant applied at GoalPill.tsx:151 -> const detail = spend ? [elapsed, spend].join(' · ') : elapsed;
run against the PR's own suite plus the new case above:
  Test Files 1 failed | 1 passed (2)   Tests 1 failed | 15 passed (16)
  GoalPill.test.tsx: 15/15 GREEN        <- the mutant survives the PR's whole suite
  new case RED: Expected "(1.2k/30.0m)" Received "! /goal paused ( · 1.2k/30.0m)"
mutant reverted:
  new case GREEN: PROBE_R1_6_FRAME: "! /goal paused (1.2k/30.0m)"

formatTokenCount floors non-positive and non-finite input to '0' (packages/cli/src/ui/statusLinePresets.ts:239-241), so the new case has to set tokensUsed above zero explicitly — leaving the helper's tokensUsed: 0 default renders (0/30.0m) and asserts a different thing than intended. The case above is the acceptance criterion: remove the filter(Boolean) join in favour of a spend-guarded one and it must go red, while the four tests this diff adds stay green against that same mutant.

中文说明

新的两段式 detail 有四种形态,测试覆盖了其中三种。spend 非空而 elapsed'' 的那一种没有测试,因为每个 pill 夹具都从 helper 继承了一个非空的 elapsed——而针对这一行的一种很自然的改写,恰恰会在这种形态上出错,却能让整个测试套件全绿。

GoalPill.test.tsxsnapshot() helper 把 activeTimeMs: 2_000updatedAt: 7_000 写死在 NOW = 10_000 下(:23、:40-44),四个新增用例没有一个覆盖这两个值,所以 formatElapsed 在其中任何一个里都不会返回 ''detail 也就总是带着 5s/2s 这个头。没被覆盖的这种形态是可达的,不是假想:formatElapsed 在小于 1000 ms 时返回 ''GoalPill.tsx:36-37),而 transitionGoal 设置 activeTimeMs = elapsedActiveTime(goal, now)goal-reducer.ts:511-521)用的正是 reduceGoalTurnFinished 加入本轮花费的那一次 transition(:221-241)——所以一个累计活跃时间不足一秒、又已经计过一轮的 Goal,在 paused / blocked / usage-limited 状态下会永久地只有花费没有耗时;active 状态下则是同一情形在头一秒内的样子。把这个 join 改写成 const detail = spend ? [elapsed, spend].join(' · ') : elapsed,这个 Goal 的底栏就读作 ◎ /goal paused ( · 1.2k/30.0m)——一个前面什么都没有的分隔符——而 pill 的 15 个用例依然全绿。

补一个覆盖该形态的用例即可收口(代码见上方)。

formatTokenCount 会把非正数和非有限值一律归到 '0'packages/cli/src/ui/statusLinePresets.ts:239-241),所以新用例必须显式把 tokensUsed 设为大于零——沿用 helper 里 tokensUsed: 0 的默认值会渲染出 (0/30.0m),断言的就是另一件事了。上面这个用例就是验收标准:把 filter(Boolean) 的 join 换成按 spend 判断的写法,它必须变红,而本 diff 新增的那四个用例在同一个变异体下应当仍然全绿。

— qwen3.8-max via Qwen Code /review (v0.23.0)

@qqqys
qqqys enabled auto-merge September 7, 2026 10:21

@yiliang114 yiliang114 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.

Reviewed at head c1ce821. Display-only change with solid parity and negative coverage:

  • The spend figure appears on the ink pill, the ink lifecycle card, the OpenTUI card (describeGoalCard), and headless TEXT output (formatGoalState) — all four shapes pinned by tests, including the "no figures before the first billed turn" and "spend-only without a budget" cases.
  • Reuses the existing formatTokenCount from statusLinePresets rather than adding a formatter; TEXT output deliberately spells numbers out for terminal scrollback/scripts while TUI abbreviates — both choices are documented in comments and pinned.
  • Event-adapter GoalSnapshotLike extension is additive/optional; legacy card props correctly show no spend.
  • Round-1 review left 3 Suggestion-level findings (docs wording about "every status card", turn-gate phrasing, one untested two-segment detail shape) — non-blocking, worth a follow-up touch-up.

CI green at head.

@qwen-code-dev-bot qwen-code-dev-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.

门禁 Review — head c1ce8211

决定:REQUEST_CHANGES. Nothing here is about behavior — the four surfaces render the right thing and every lane is green. Two sentences this diff introduces state the rule incorrectly, and one new branch is unpinned; all three are minutes of work, and I expect a fast re-review after them.

1. docs/users/features/goals.md:19 — "every status card" is false, and this PR's own test proves it

The added sentence promises that "the footer pill and every status card show what it has spent". The legacy goal_status card shows no figure, in ink and OpenTUI alike: projectGoalStateToLegacy emits only iterations/setAt/durationMs/lastReason (packages/core/src/goals/goal-legacy-projection.ts:56-72, verified at this head) and it is a live path, called from MessageEmitter.ts:57, history-replayer.ts:138 and transcript-replay.ts:995. The new test leaves the legacy card without spend it cannot know (GoalStatusMessage.test.tsx:190) pins exactly that absence. Web Shell is the second family that shows nothing until PR #11254 lands, so scoping to "the CLI's own cards" is not enough either. The page already scopes claims away from Web Shell elsewhere ("nor yet in Web Shell or other ACP-driven sessions"), so an unscoped "every" reads as a coverage claim a user will act on and find wrong. Name the surfaces this diff changed.

2. The gate is recorded spend, but the prose says billed turns

GoalPill.tsx:24-27 says "A Goal that has not billed a turn yet shows no figures", goals.md:19 says "Once a Goal has billed a turn", and four of the new test titles do too (GoalPill.test.tsx:140, :163, GoalStatusMessage.test.tsx:177, live-session-model.test.ts:687) — while all four implementations gate on spend (goal.tokensUsed <= 0 at GoalPill.tsx:30). Those two predicates differ on a state that is reachable in production and documented in-repo: usageMetadata is optional and only set from chunk.usageMetadata, and accumulateGoalTurnTokens returns early unless totalTokenCount is a finite number greater than zero (chatRecordingService.ts:2062-2067, read at this head), while goal-reducer.ts:232-233 still increments turnCount in a reduction that added no tokens. So a Goal can bill turns and show no figure anywhere. The spend gate is right and must stay — the point is that a maintainer following this prose goes to the wrong predicate, and the "fix" they would apply re-adds the 0/30.0m the comment exists to reject. Say "has recorded token spend" in the JSDoc, the doc sentence and the three titles, and add one clause to the doc for the case where responses carry no usage at all.

3. One shape of the new detail has no fixture

const detail = [elapsed, spend].filter(Boolean).join(' · ') (GoalPill.tsx:151) has four input shapes and three are tested. spend non-empty with elapsed === '' has none, because every pill fixture inherits a non-empty elapsed — so a rewrite that drops the filter(Boolean) (or joins unconditionally) still passes the whole suite while emitting " ( · 1.2k/30.0m)". One fixture closes it.

Not blocking, for the record

The behavior itself checked out: the spend gate handles the unbounded case by showing spend only, the zero-spend and missing-field cases are distinct, and the OpenTUI model's string follows ink's rule (tokensUsed > 0, spend-only when tokenBudget is absent), so all four surfaces agree on when a figure appears. CI at this head is complete and green — Test (ubuntu-latest) 40m18s, Lint & Static 21m50s, Integration Tests (no-AK) 26m1s, the TUI parity and OpenTUI no-flicker gates, review-pr — and there is no open Critical on the PR; yiliang114's approval is on this head. Round it back for a vote once the three edits are in.

@chiga0 chiga0 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.

Reviewed at head c1ce8211 with the applicable suites executed locally on linux / node v24.20.0. Approving — the behavior is clean; two doc-accuracy nits ride along, and one open gate item is refuted by execution.

Executed: full monorepo build clean · cli nonInteractiveCli + GoalPill + GoalStatusMessage + live-session-model 220/220 (+1 skip). All four surfaces (ink pill, ink card, OpenTUI card, headless TEXT) agree on the same gate (tokensUsed > 0, spend-only when no budget), reusing formatTokenCount rather than a new formatter; the snapshot-type extensions are optional/additive, so older daemons and legacy cards degrade to no figures.

Cross-check of the open gate review at this head:

  • Item 1 (goals.md "every status card"): confirmedprojectGoalStateToLegacy carries no spend fields and is a live path, and the PR's own test pins the legacy card's absence. The sentence should name the surfaces this diff changed. Minor, docs-only.
  • Item 2 ("billed a turn" vs recorded spend): confirmed — every implementation gates on tokensUsed > 0 while the prose and four test titles say "billed a turn"; a turn whose provider response lacks usageMetadata bills but shows nothing. Wording fix (or gate comment), minor.
  • Item 3 (the [elapsed, spend] join shape unpinned): refuted by execution at this head — dropping filter(Boolean) fails 3 tests in GoalPill.test.tsx (adds the current active span to persisted active time, shows no figures for a Goal that has not billed a turn, keeps paused elapsed time frozen while wall clock advances). The empty-elapsed-with-spend cell may still lack a dedicated fixture, but the named mutant does not survive, so this item should not hold the vote.

With items 1-2 being minutes of wordsmithing, no behavioral blocker exists at this head. Not covered: a live headless run against a real model (fixtures only), Windows/macOS (linux only).

Reviewed with AI assistance.

@qqqys
qqqys disabled auto-merge September 7, 2026 11:01
@qqqys
qqqys added this pull request to the merge queue Sep 7, 2026
Merged via the queue into QwenLM:main with commit 50b3305 Sep 7, 2026
135 checks passed
qwen-code-dev-bot added a commit to qqqys/qwen-code that referenced this pull request Sep 7, 2026
Both main (QwenLM#11248) and this branch inserted a new paragraph at the same point in the Goals introduction. The two describe different surfaces and are both kept: main's covers the footer pill and status cards rendering spend against the budget window, this branch's covers the figures and standing guidance the model receives at the start of each autonomous turn. Main's paragraph goes first because it defines "the window it is allowed" and names model.goalTokenBudget, which the second paragraph then relies on.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.1.

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.

5 participants