Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type GoalStatusKind =
| 'cleared'
| 'failed'
| 'aborted'
| 'usage_limited'
| 'paused'
| 'checking';

Expand All @@ -35,6 +36,7 @@ const VALID_GOAL_KINDS = new Set<string>([
'cleared',
'failed',
'aborted',
'usage_limited',
// A paused goal is not running. Dropping it here left the footer and
// the active-goal derivation falling through to the previous `set`
// card, so the UI kept claiming autonomous work was under way.
Expand Down Expand Up @@ -128,6 +130,11 @@ function getTitle(
title: t('goal.aborted'),
colorClass: styles.warning,
};
case 'usage_limited':
return {
title: t('goal.usageLimited'),
colorClass: styles.warning,
};
case 'paused':
return {
title: t('goal.paused'),
Expand Down Expand Up @@ -159,6 +166,7 @@ export function GoalStatusMessage({
status.kind === 'achieved' ||
status.kind === 'failed' ||
status.kind === 'aborted' ||
status.kind === 'usage_limited' ||
status.kind === 'paused') &&
status.lastReason?.trim();
const reasonLabel =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,35 @@ describe('SystemMessage — prompt_cancelled marker', () => {
});
});

describe('SystemMessage — goal status', () => {
it.each([
['en', 'Goal usage limited', 'Last check: token budget reached'],
['zh-CN', '目标用量受限', '上次检查: token budget reached'],
] as const)(
'renders a usage-limited goal distinctly in %s',
(language, title, reason) => {
const container = render(
<SystemMessage
content=""
variant="info"
source="goal"
data={{
kind: 'usage_limited',
condition: 'finish the evaluation',
lastReason: 'token budget reached',
}}
/>,
language,
);

expect(container.textContent).toContain(title);
expect(container.textContent).toContain(reason);
expect(container.textContent).not.toContain('Goal aborted');
expect(container.textContent).not.toContain('目标已中止');
},
);
});

describe('SystemMessage — terminal turn error copy', () => {
it('copies the displayed error without triggering retry', async () => {
vi.useFakeTimers();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3179,6 +3179,80 @@ describe('DaemonSessionProvider', () => {
});
});

it('restores usage-limited semantics from canonical goal state metadata', async () => {
const session = createMockSession({
events: async function* goalStatusEvents() {
yield {
id: 13,
v: 1,
type: 'session_update',
data: {
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
goalState: {
v: 2,
activity: 'idle',
goal: {
goalId: 'goal-limited',
revision: 2,
objective: 'finish the evaluation',
status: 'usage_limited',
limitKind: 'token_budget',
evidenceCursor: { recordId: 'goal-record' },
turnCount: 4,
activeTimeMs: 5000,
tokensUsed: 1000,
createdAt: 1234,
updatedAt: 2345,
lastReason: 'token budget reached',
},
},
goalStatus: {
kind: 'aborted',
condition: 'finish the evaluation',
iterations: 4,
durationMs: 5000,
lastReason: 'token budget reached',
},
},
},
},
};
},
});
sdkMocks.sessions.push(session);
let blocks: readonly DaemonTranscriptBlock[] = [];

function Harness() {
blocks = useDaemonTranscriptBlocks();
return null;
}

await renderWithProvider(<Harness />, {
autoConnect: true,
autoReconnect: false,
});
await act(async () => {
await flushPromises();
});

expect(blocks).toContainEqual(
expect.objectContaining({
kind: 'status',
source: 'goal',
data: {
kind: 'usage_limited',
condition: 'finish the evaluation',
iterations: 4,
durationMs: 5000,
lastReason: 'token budget reached',
},
}),
);
});

it('does not overwrite a streamed goal update with the session-load snapshot', async () => {
const pendingGoal = createDeferred<GoalStateResponse>();
const streamedGoal: GoalStateResponse['snapshot'] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4817,7 +4817,10 @@ function normalizeGoalStatusEvent(event: DaemonEvent): DaemonUiEvent | null {
if (!isRecord(meta)) return null;
const status = normalizeGoalStatus(meta['goalStatus']);
if (status) {
return createGoalStatusUiEvent(event, status);
return createGoalStatusUiEvent(
event,
restoreCanonicalGoalStatusKind(status, meta['goalState']),
);
}

const terminal = normalizeGoalTerminal(meta['goalTerminal']);
Expand Down Expand Up @@ -4855,6 +4858,18 @@ function createGoalStatusUiEvent(
};
}

function restoreCanonicalGoalStatusKind(
status: Record<string, unknown>,
goalState: unknown,
): Record<string, unknown> {
// V2 updates pair a legacy card with canonical state. Keep the legacy wire
// value stable for older clients while restoring its precise Web Shell label.
if (status['kind'] !== 'aborted' || !isRecord(goalState)) return status;
const goal = goalState['goal'];
if (!isRecord(goal) || goal['status'] !== 'usage_limited') return status;
Comment on lines +4867 to +4869

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] R1-1: The pass-through branches of this restore guard have no test witness: nothing pins that a legacy kind: 'aborted' card stays aborted when no canonical goalState is co-emitted, or when the canonical status is not 'usage_limited'. Core's legacy projection maps 'blocked' goals onto this same legacy 'aborted' kind (case 'blocked': case 'usage_limited': return 'aborted'; in packages/core/src/goals/goal-legacy-projection.ts), so if a future edit weakens the guard — dropping the goal['status'] !== 'usage_limited' comparison, or keying the restore on goalState presence alone — every blocked goal's transcript card silently re-labels to "Goal usage limited" and the suite stays green: every kind: 'aborted' fixture in packages/web-shell/client today is paired with canonical status: 'usage_limited', and the only restore test asserts the positive direction.

Witness:

guard mutated (goal['status'] !== 'usage_limited' removed):
  DaemonSessionProvider.test.tsx + SystemMessage.test.tsx — Tests 306 passed (306)  ← suite stays green
negative probe added beside the restore test:
  × keeps aborted label when canonical state is blocked
      Expected: data ObjectContaining { "kind": "aborted" }
      Received: data { "kind": "usage_limited", "condition": "finish the evaluation", ... }

Add a negative case beside restores usage-limited semantics from canonical goal state metadata in DaemonSessionProvider.test.tsx: emit _meta.goalStatus with kind: 'aborted' paired (a) with goalState.goal.status: 'blocked' and (b) with no goalState at all, and assert the transcript block keeps data.kind: 'aborted':

expect(blocks).toContainEqual(
  expect.objectContaining({
    kind: 'status',
    source: 'goal',
    data: expect.objectContaining({ kind: 'aborted' }),
  }),
);

The fixture must include a non-empty conditionnormalizeGoalStatus rejects any goal status without one (if (!condition) return null;, DaemonSessionProvider.tsx:4876), or the card silently disappears — and it must distinguish by canonical goalState.goal.status, because blocked goals arrive on the wire as the identical legacy 'aborted' kind, the only differentiator available to the client.

The new test must go red if the guard is removed: delete goal['status'] !== 'usage_limited' from the condition and run it — the blocked case should fail with kind: 'usage_limited'.

中文说明

这个恢复逻辑的直通分支没有测试锁定:当没有同发的规范 goalState,或者规范状态不是 'usage_limited' 时,旧的 kind: 'aborted' 卡片应保持 aborted,但目前没有任何测试钉住这一点。core 的旧版投影会把 'blocked' 目标映射成同一个旧类型 'aborted'packages/core/src/goals/goal-legacy-projection.ts 中的 case 'blocked': case 'usage_limited': return 'aborted';),因此如果未来有人放宽这个守卫——删掉 goal['status'] !== 'usage_limited' 比较,或改成只要存在 goalState 就恢复——所有被阻断目标的卡片都会被悄悄改标为 "Goal usage limited",而整个测试套件依然全绿:目前 packages/web-shell/client 里所有 kind: 'aborted' 的测试数据都与规范状态 'usage_limited' 成对出现,唯一的恢复测试也只断言了正向转换。

证据(变异验证):删掉守卫中的 goal['status'] !== 'usage_limited' 后,DaemonSessionProvider.test.tsx + SystemMessage.test.tsx 仍为 306/306 全部通过;在恢复测试旁加入负向探针测试后,blocked 场景立即失败:期望 kind: "aborted",实际收到 kind: "usage_limited"

建议在 DaemonSessionProvider.test.tsx 的新恢复测试旁补一个负向用例:_meta.goalStatuskind: 'aborted',分别(a)与 goalState.goal.status: 'blocked' 成对、(b)完全不带 goalState,断言历史消息块的 data.kind 保持 'aborted'

约束:测试数据必须包含非空 condition——normalizeGoalStatus 会拒绝缺少 condition 的状态(if (!condition) return null;DaemonSessionProvider.tsx:4876),否则卡片会静默消失;同时只能通过规范的 goalState.goal.status 来区分场景,因为 blocked 目标在传输层就是以相同的旧类型 'aborted' 到达的,这是客户端唯一可用的区分依据。

新测试必须在移除守卫时变红:删除条件中的 goal['status'] !== 'usage_limited' 后运行该测试,blocked 场景应以 kind: 'usage_limited' 失败。

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

return { ...status, kind: 'usage_limited' };
}

function normalizeGoalStatus(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const kind = getString(value, 'kind');
Expand All @@ -4864,6 +4879,7 @@ function normalizeGoalStatus(value: unknown): Record<string, unknown> | null {
kind !== 'achieved' &&
kind !== 'failed' &&
kind !== 'aborted' &&
kind !== 'usage_limited' &&
// Rejecting 'paused' made every surface keep showing a paused goal as
// actively running: the card never rendered and the active-goal
// derivation fell back to the previous 'set' card.
Expand Down
61 changes: 61 additions & 0 deletions packages/web-shell/client/e2e/visuals/screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,67 @@ for (const theme of THEMES) {
await captureScreenshot(page, `session-transcript-${theme}`);
});

test(`usage-limited goal status`, async ({ page }, testInfo) => {
// Seed the compatibility card together with its canonical V2 state, as
// emitted by both live goal updates and transcript replay.
const usageLimitedGoalEvent: DaemonEvent = {
id: 2,
v: 1,
type: 'session_update',
data: {
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
goalState: {
v: 2,
activity: 'idle',
goal: {
goalId: 'goal-visual-usage-limited',
revision: 2,
objective: 'Finish the evaluation suite',
status: 'usage_limited',
limitKind: 'token_budget',
evidenceCursor: { recordId: 'goal-visual-record' },
turnCount: 4,
activeTimeMs: 5000,
tokensUsed: 1000,
createdAt: 1234,
updatedAt: 2345,
lastReason: 'Token budget reached',
},
},
goalStatus: {
kind: 'aborted',
condition: 'Finish the evaluation suite',
iterations: 4,
durationMs: 5000,
lastReason: 'Token budget reached',
},
},
},
},
};
const scenario = createWebShellDaemonScenario({
events: [
userTextEvent('Finish the evaluation suite.', { id: 1 }),
usageLimitedGoalEvent,
turnCompleteEvent('prompt-goal-usage-limited', { id: 3 }),
],
});
const daemon = await installScenario(
page,
scenario,
resolveBaseURL(testInfo),
);
await gotoSession(page, scenario, daemon, theme);

const messageList = page.locator('[data-web-shell-message-list]');
await expect(messageList).toContainText('Goal usage limited');
await expect(messageList).toContainText('Token budget reached');
await captureScreenshot(page, `goal-usage-limited-${theme}`);
});

test(`terminal turn error`, async ({ browser, page }, testInfo) => {
const baseURL = resolveBaseURL(testInfo);
const scenario = createTerminalTurnErrorScenario(
Expand Down
2 changes: 2 additions & 0 deletions packages/web-shell/client/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2250,6 +2250,7 @@ const EN: Messages = {
'mcp.userMcp': 'Global MCP',
'mcp.workingDirectory': 'Working Directory',
'goal.aborted': 'Goal aborted',
'goal.usageLimited': 'Goal usage limited',
'goal.paused': 'Goal paused',
'goal.achieved': 'Goal achieved',
'goal.check': 'Goal check',
Expand Down Expand Up @@ -5519,6 +5520,7 @@ const ZH: Messages = {
'mcp.userMcp': '全局 MCP',
'mcp.workingDirectory': '工作目录',
'goal.aborted': '目标已中止',
'goal.usageLimited': '目标用量受限',
'goal.paused': '目标已暂停',
'goal.achieved': '目标已达成',
'goal.check': '目标检查',
Expand Down
Loading