diff --git a/packages/web-shell/client/components/messages/GoalStatusMessage.tsx b/packages/web-shell/client/components/messages/GoalStatusMessage.tsx index 25b7b3adb48..ec3c886debc 100644 --- a/packages/web-shell/client/components/messages/GoalStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/GoalStatusMessage.tsx @@ -10,6 +10,7 @@ export type GoalStatusKind = | 'cleared' | 'failed' | 'aborted' + | 'usage_limited' | 'paused' | 'checking'; @@ -35,6 +36,7 @@ const VALID_GOAL_KINDS = new Set([ '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. @@ -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'), @@ -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 = diff --git a/packages/web-shell/client/components/messages/SystemMessage.test.tsx b/packages/web-shell/client/components/messages/SystemMessage.test.tsx index dd9bddbcd4f..3c60ff32947 100644 --- a/packages/web-shell/client/components/messages/SystemMessage.test.tsx +++ b/packages/web-shell/client/components/messages/SystemMessage.test.tsx @@ -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( + , + 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(); diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx index 22f6e4e3758..f5871cd5fe3 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx @@ -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(, { + 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(); const streamedGoal: GoalStateResponse['snapshot'] = { diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx index 8067b11df56..076bfdf3586 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx @@ -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']); @@ -4855,6 +4858,18 @@ function createGoalStatusUiEvent( }; } +function restoreCanonicalGoalStatusKind( + status: Record, + goalState: unknown, +): Record { + // 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; + return { ...status, kind: 'usage_limited' }; +} + function normalizeGoalStatus(value: unknown): Record | null { if (!isRecord(value)) return null; const kind = getString(value, 'kind'); @@ -4864,6 +4879,7 @@ function normalizeGoalStatus(value: unknown): Record | 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. diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index d7568d9303e..1c6abcb27db 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -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( diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 8dd280af11e..ed7669be820 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -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', @@ -5519,6 +5520,7 @@ const ZH: Messages = { 'mcp.userMcp': '全局 MCP', 'mcp.workingDirectory': '工作目录', 'goal.aborted': '目标已中止', + 'goal.usageLimited': '目标用量受限', 'goal.paused': '目标已暂停', 'goal.achieved': '目标已达成', 'goal.check': '目标检查',