@@ -439,6 +466,19 @@ export function GoalsDialog({
)}
+ {checkpointLine && (
+
+
+ {t('goal.checkpoint')}:
+ {' '}
+ {checkpointLine}
+
+ )}
+
{t(`goal.status.${goal.status}`)}
diff --git a/packages/web-shell/client/daemon/session/mappers.test.ts b/packages/web-shell/client/daemon/session/mappers.test.ts
index 5e689294eb5..a8340ddc13e 100644
--- a/packages/web-shell/client/daemon/session/mappers.test.ts
+++ b/packages/web-shell/client/daemon/session/mappers.test.ts
@@ -1102,6 +1102,49 @@ describe('updateConnectionFromDaemonEvent', () => {
expect(Object.keys(goal!)).not.toContain('activeTimeBudgetMs');
});
+ it('carries checkpoint health through from the wire', () => {
+ // Same pin as limitKind: the field-by-field rebuild must not drop the
+ // stall streak or the failure the Goals dialog shows before a stop.
+ const next = applyEvent(
+ { status: 'connected', workspaceCwd: '/workspace' },
+ {
+ id: 1,
+ v: 1,
+ type: 'session_update',
+ data: {
+ update: {
+ sessionUpdate: 'agent_message_chunk',
+ _meta: {
+ goalState: {
+ v: 2,
+ activity: 'idle',
+ goal: {
+ goalId: 'goal-1',
+ revision: 3,
+ objective: 'ship it',
+ status: 'active',
+ evidenceCursor: { recordId: 'record-1' },
+ turnCount: 2,
+ activeTimeMs: 10,
+ createdAt: 1,
+ updatedAt: 2,
+ checkpointStalls: 2,
+ lastCheckpointFailure: 'Error: provider failed',
+ },
+ },
+ },
+ },
+ },
+ } as DaemonEvent,
+ );
+
+ expect(next.goalState?.goal).toMatchObject({
+ status: 'active',
+ checkpointStalls: 2,
+ lastCheckpointFailure: 'Error: provider failed',
+ });
+ });
+
it('drops an unknown limitKind rather than passing it through', () => {
const next = applyEvent(
{ status: 'connected', workspaceCwd: '/workspace' },
diff --git a/packages/web-shell/client/daemon/session/mappers.ts b/packages/web-shell/client/daemon/session/mappers.ts
index 885886c288d..ac8cbb16cc3 100644
--- a/packages/web-shell/client/daemon/session/mappers.ts
+++ b/packages/web-shell/client/daemon/session/mappers.ts
@@ -690,6 +690,8 @@ function getGoalState(
) {
return undefined;
}
+ const checkpointStalls = getNumber(source, 'checkpointStalls');
+ const lastCheckpointFailure = getString(source, 'lastCheckpointFailure');
const lastReason = getString(source, 'lastReason');
const limitKindRaw = getString(source, 'limitKind');
const limitKind =
@@ -717,6 +719,10 @@ function getGoalState(
...(activeTimeBudgetMs !== undefined ? { activeTimeBudgetMs } : {}),
createdAt,
updatedAt,
+ ...(checkpointStalls !== undefined && checkpointStalls > 0
+ ? { checkpointStalls }
+ : {}),
+ ...(lastCheckpointFailure ? { lastCheckpointFailure } : {}),
...(lastReason ? { lastReason } : {}),
...(limitKind ? { limitKind } : {}),
},
diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx
index 2ccf863c213..74dd3d12033 100644
--- a/packages/web-shell/client/i18n.tsx
+++ b/packages/web-shell/client/i18n.tsx
@@ -2369,6 +2369,10 @@ const EN: Messages = {
'goal.judge': 'Judge',
'goal.label': 'Goal',
'goal.lastCheck': 'Last check',
+ 'goal.checkpoint': 'Checkpoint',
+ 'goal.checkpointStalled': (v) =>
+ `${v?.count ?? 0}/${v?.limit ?? 0} checks stalled`,
+ 'goal.checkpointFailed': 'last evidence checkpoint failed',
'goal.notYetMet': 'not yet met',
'goal.set': 'Goal set',
'goal.statusActive': '/goal active',
@@ -5888,6 +5892,10 @@ const ZH: Messages = {
'goal.judge': '判断',
'goal.label': '目标',
'goal.lastCheck': '上次检查',
+ 'goal.checkpoint': '检查点',
+ 'goal.checkpointStalled': (v) =>
+ `连续 ${v?.count ?? 0}/${v?.limit ?? 0} 次检查停滞`,
+ 'goal.checkpointFailed': '最近一次证据检查点失败',
'goal.notYetMet': '尚未满足',
'goal.set': '目标已设置',
'goal.statusActive': '/goal 运行中',
diff --git a/packages/web-shell/client/utils/goalGate.drift.test.ts b/packages/web-shell/client/utils/goalGate.drift.test.ts
new file mode 100644
index 00000000000..dc6c8ac65ce
--- /dev/null
+++ b/packages/web-shell/client/utils/goalGate.drift.test.ts
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import {
+ goalCheckpointHealthVisible,
+ type GoalCheckpointHealthRecord,
+} from './goalGate';
+
+// The Web Shell bundles for the browser and does not depend on
+// `@qwen-code/qwen-code-core`, so the checkpoint-health rule is copied into
+// `goalGate.ts` rather than imported. A comment asking the next person to keep
+// the copies in sync is not a mechanism: read core's function body from source
+// and run both over every combination of the fields the rule reads, so a
+// branch added to either copy without the other fails here.
+const protocolSource = readFileSync(
+ fileURLToPath(
+ new URL('../../../core/src/goals/goal-protocol.ts', import.meta.url),
+ ),
+ 'utf8',
+);
+
+function coreCheckpointHealthVisible(): (
+ goal: GoalCheckpointHealthRecord,
+) => boolean {
+ const match = protocolSource.match(
+ /export function goalCheckpointHealthVisible\(goal: \{[^}]*\}\): boolean \{\n([\s\S]*?)\n\}\n/,
+ );
+ if (!match) {
+ throw new Error(
+ 'could not locate `goalCheckpointHealthVisible` in core goal-protocol.ts',
+ );
+ }
+ // The body reads only `goal` and plain JavaScript, so it runs as written.
+ return new Function('goal', match[1]) as (
+ goal: GoalCheckpointHealthRecord,
+ ) => boolean;
+}
+
+const STATUSES = [
+ undefined,
+ 'active',
+ 'paused',
+ 'blocked',
+ 'usage_limited',
+ 'complete',
+];
+const STALLS = [undefined, 0, 1, 3];
+const FAILURES = [undefined, '', ' ', '\r', 'Error: provider failed'];
+const LIMIT_KINDS = [undefined, 'evidence_catalog', 'checkpoint_request'];
+
+describe('goalCheckpointHealthVisible drift vs core', () => {
+ it('sanity-checks that core function body was parsed', () => {
+ const core = coreCheckpointHealthVisible();
+ expect(core({ status: 'complete', checkpointStalls: 3 })).toBe(false);
+ expect(core({ status: 'paused', checkpointStalls: 1 })).toBe(true);
+ });
+
+ it('agrees with core on every combination of the fields it reads', () => {
+ const core = coreCheckpointHealthVisible();
+ const disagreements: string[] = [];
+ for (const status of STATUSES) {
+ for (const checkpointStalls of STALLS) {
+ for (const lastCheckpointFailure of FAILURES) {
+ for (const limitKind of LIMIT_KINDS) {
+ const goal = {
+ status,
+ checkpointStalls,
+ lastCheckpointFailure,
+ limitKind,
+ };
+ if (core(goal) !== goalCheckpointHealthVisible(goal)) {
+ disagreements.push(JSON.stringify(goal));
+ }
+ }
+ }
+ }
+ }
+ expect(disagreements).toEqual([]);
+ });
+});
diff --git a/packages/web-shell/client/utils/goalGate.ts b/packages/web-shell/client/utils/goalGate.ts
index 5465482365a..950d7bb66e7 100644
--- a/packages/web-shell/client/utils/goalGate.ts
+++ b/packages/web-shell/client/utils/goalGate.ts
@@ -51,3 +51,32 @@ export function canResumeGoal(goal: GoalResumeGateRecord): boolean {
if (goal.status === 'complete' || goal.status === 'active') return false;
return true;
}
+
+/** The slice of a Goal record the checkpoint-health gate reads. */
+export interface GoalCheckpointHealthRecord {
+ status?: string;
+ checkpointStalls?: number;
+ lastCheckpointFailure?: string;
+ limitKind?: string;
+}
+
+/**
+ * Whether a Goal card shows checkpoint health: never on a completed Goal,
+ * always during a stall streak, the failure that stopped a Goal whose
+ * checkpoint request was too large, and any other failure that spent no stall
+ * only while the Goal is active.
+ *
+ * A copy of core's `goalCheckpointHealthVisible`, which this browser bundle
+ * cannot import, so the terminal cards and this one agree on which records
+ * show it. `goalGate.drift.test.ts` runs core's own function body against this
+ * copy, so a branch added to either one without the other fails there.
+ */
+export function goalCheckpointHealthVisible(
+ goal: GoalCheckpointHealthRecord,
+): boolean {
+ if (goal.status === 'complete') return false;
+ if ((goal.checkpointStalls ?? 0) > 0) return true;
+ const failed = Boolean(goal.lastCheckpointFailure?.trim());
+ if (goal.limitKind === 'checkpoint_request') return failed;
+ return (goal.status ?? 'active') === 'active' && failed;
+}