Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/users/features/goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Each turn the session takes on its own reports what the Goal has spent so far, h

A long Goal periodically compresses the evidence it has recorded into checkpoint claims with a side model check, so later turns and the verifier still have it to cite. The check is bounded by [`model.goalCheckpointTimeoutSeconds`](../configuration/settings.md), 180 seconds by default. A reply wrapped in a markdown fence is read without the fence. If its claims overrun the aggregate byte budget, include a claim over the per-claim character limit, number more than one checkpoint may hold, cite an id that was not in the request, or change the proof kind of a source they cite, it makes one corrective model call that names what was wrong, and both calls share that ceiling. A reply that is not a JSON object holding a non-empty `claims` array gets no corrective call, and neither does one with any malformed claim, even beside usable ones: an extra key at either level, an unrecognised `proofKind`, an empty claim, or a `sourceRefs` list that is empty, holds a non-string or empty id, repeats an id, or holds more than 32 ids. A check that does not finish in time is abandoned as inconclusive; it counts toward the checkpoint stall limit only when the evidence window has overflowed, while a non-overflowing check preserves the streak and retries on a later turn. The calls are streamed, so the per-request transport timeout bounds only connect and first response, and the ceiling itself stops at the stream guards' 15-minute lifetime cap because past that the guard, not the setting, ends the check. That 15-minute limit on the setting is fixed, and raising the stream guard's own cap does not lift it.

A failing checkpoint shows up before it stops the Goal. While an active Goal's stall streak runs, the footer pill switches to `checkpoint N/3 stalled` on its own; once the Goal pauses or stops, the pill shows that status instead. The web shell's Goal status strip shows the count whatever the status. Whenever a terminal Goal status card is rendered, for example by `/goal` or by a pause, resume or verifier card, it shows how many consecutive checks have stalled out of the three the Goal allows, together with the last failure while one is recorded; the web shell's Goals dialog and headless `/goal` text output show the same line, while the web shell's transcript cards for Goal events show only the stop reason, and the model sees both fields when it reads the Goal. A check that fails while the window still has room is shown too, without spending a stall, but only while the Goal is active, or when that failure is itself what stopped the Goal, as with a checkpoint request too large to send. A checkpoint stop for any other reason clears the failure and keeps the streak, and a completed Goal shows no checkpoint line. The failure is kept as a single line with control characters removed. A Goal stopped by three stalled checkpoints names what the last one ran into. A check that could not fit the window within the checkpoint claim bounds, whether a full claim list that still left evidence behind or claims over the claim count or size budget, means the objective produces more evidence than one window holds, so narrow it. An answer that could not be folded into claims means the checkpoint model is not returning the structured output it is asked for, and narrowing the objective does not fix that. A check that never answered can mean an unreachable or rate-limited provider, a check that did not finish within `model.goalCheckpointTimeoutSeconds`, or an error in the check itself; the recorded failure says which. Resuming after any of the three starts a fresh evidence window.

## Interrupting a Goal

Cancelling a Goal turn pauses the Goal. Press Esc while the model is answering or while its tools are still running, and the turn stops, the Goal moves to `paused`, and the card and `/goal` both say why it stopped. Nothing continues until you run `/goal resume`.
Expand Down
56 changes: 56 additions & 0 deletions packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9348,6 +9348,62 @@ describe('formatGoalState', () => {
);
});

it('writes no control sequence from a stop reason to stdout', () => {
// A pause reason can embed a raw provider error.
const output = formatGoalState(
goalSnapshot({
status: 'paused',
lastReason: 'paused\r\u001b]52;c;ZXh0cmFjdGVk\u0007 by user',
}),
'status',
);

expect(output).toContain('Reason: paused');
expect(output).not.toContain('\r');
expect(output).not.toContain('\u001b');
expect(output).not.toContain('\u0007');
});

it('names the checkpoint failure below the stop reason', () => {
// The stop reason names the kind of checkpoint failure; only this line
// says which one it was.
expect(
formatGoalState(
goalSnapshot({
status: 'usage_limited',
lastReason: 'Checkpoints stalled.',
checkpointStalls: 3,
lastCheckpointFailure:
'Error: Goal checkpoint verifier timed out after 30000ms',
}),
'status',
),
).toBe(
'Goal usage limited: ship the release notes\nReason: Checkpoints stalled.\nCheckpoint: 3/3 stalled · Error: Goal checkpoint verifier timed out after 30000ms',
);
});

it('shows checkpoint health under the rule the interactive cards use', () => {
expect(
formatGoalState(
goalSnapshot({ lastCheckpointFailure: 'Error: provider failed' }),
'status',
),
).toBe(
'Goal active: ship the release notes\nCheckpoint: last check failed · Error: provider failed',
);
expect(
formatGoalState(
goalSnapshot({
status: 'complete',
checkpointStalls: 1,
lastCheckpointFailure: 'Error: provider failed',
}),
'status',
),
).not.toContain('Checkpoint');
});

it('has no usage to report for a cleared Goal', () => {
expect(
formatGoalState({ v: 2, activity: 'idle', goal: null }, 'clear'),
Expand Down
19 changes: 14 additions & 5 deletions packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
ServerLlmStreamEvent,
} from '@qwen-code/qwen-code-core';
import { isSlashCommand } from './ui/utils/commandUtils.js';
import { sanitizeTerminalText } from './ui/utils/textUtils.js';
import { isInlineModelOverrideAllowed } from './utils/acpModelUtils.js';
import type { LoadedSettings } from './config/settings.js';
import {
Expand Down Expand Up @@ -80,6 +81,7 @@ import {
getErrorType,
getActiveInteractionSpan,
buildGoalContinuationParts,
goalCheckpointHealthLine,
} from '@qwen-code/qwen-code-core';
import type { Content, Part, PartListUnion } from '@google/genai';
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
Expand Down Expand Up @@ -287,14 +289,21 @@ export function formatGoalState(
: `${used} of ${goal.tokenBudget.toLocaleString('en-US')} tokens`,
);
}
const withUsage =
usage.length > 0 ? `${summary}\nUsage: ${usage.join(' · ')}` : summary;
const lines = [summary];
if (usage.length > 0) lines.push(`Usage: ${usage.join(' · ')}`);
// Every non-active status now carries a reason, so gating on two of them
// drops a paused Goal's reason from TEXT output while STREAM_JSON still
// ships it -- and the user doc promises every pause states why.
return goal.status !== 'active' && goal.lastReason
? `${withUsage}\nReason: ${goal.lastReason}`
: withUsage;
// Both lines are written to stdout as they are, so both are sanitized: a
// pause reason can embed a raw provider error.
if (goal.status !== 'active' && goal.lastReason) {
lines.push(`Reason: ${sanitizeTerminalText(goal.lastReason)}`);
}
// The checkpoint line the interactive cards show, in the same words: a
// checkpoint stop reason names the kind of failure, only this says which.
const checkpoint = goalCheckpointHealthLine(goal, sanitizeTerminalText);
if (checkpoint !== undefined) lines.push(`Checkpoint: ${checkpoint}`);
return lines.join('\n');
}

async function claimUserGoalTurn(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
import { GOAL_CHECKPOINT_STALL_LIMIT } from '@qwen-code/qwen-code-core';
import { GOAL_CHECKPOINT_STALL_LIMIT as SDK_GOAL_CHECKPOINT_STALL_LIMIT } from '@qwen-code/sdk/daemon';

// The Web Shell renders the checkpoint stall streak against the SDK's
// hand-duplicated copy of this limit, while the runtime stops a Goal at
// Core's. The SDK has no dependency path to Core, so pin the two copies here,
// where both packages are importable: a drift would show users the wrong N/3
// rather than fail a build.
describe('goal checkpoint stall limit wire contract', () => {
it('is identical across core and the SDK', () => {
expect(SDK_GOAL_CHECKPOINT_STALL_LIMIT).toBe(GOAL_CHECKPOINT_STALL_LIMIT);
});
});
34 changes: 34 additions & 0 deletions packages/cli/src/ui/components/GoalPill.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,40 @@ describe('GoalPill', () => {
unmount();
});

it('warns about stalled checkpoints before the stall breaker stops the Goal', () => {
vi.setSystemTime(NOW);
const { lastFrame, unmount } = renderPill({
snapshot: snapshot('active', 'running', {
checkpointStalls: 2,
lastCheckpointFailure: 'Error: provider failed',
}),
});

expect(lastFrame()).toContain('! /goal checkpoint 2/3 stalled');
// The footer has no room for the failure itself; the status card has it.
expect(lastFrame()).not.toContain('provider failed');
unmount();
});

it('keeps the plain labels when no checkpoint has stalled', () => {
vi.setSystemTime(NOW);
// A failure on a window with room spends no stall and is the card's to
// show; the footer stays quiet until the streak starts.
const quiet = renderPill({
snapshot: snapshot('active', 'running', {
lastCheckpointFailure: 'Error: provider failed',
}),
});
expect(quiet.lastFrame()).toContain('/goal active');
quiet.unmount();

const checking = renderPill({
snapshot: snapshot('active', 'verifying', { checkpointStalls: 1 }),
});
expect(checking.lastFrame()).toContain('/goal checking');
checking.unmount();
});

it('adds the current active span to persisted active time', () => {
vi.setSystemTime(NOW);
const { lastFrame, unmount } = renderPill({
Expand Down
30 changes: 22 additions & 8 deletions packages/cli/src/ui/components/GoalPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { useEffect, useState } from 'react';
import { Text } from 'ink';
import { elapsedActiveTime } from '@qwen-code/qwen-code-core/goals/goal-reducer.js';
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
import type { GoalSnapshotV2 } from '@qwen-code/qwen-code-core/goals/goal-protocol.js';
import {
GOAL_CHECKPOINT_STALL_LIMIT,
type GoalSnapshotV2,
} from '@qwen-code/qwen-code-core/goals/goal-protocol.js';
import type { GoalRuntime } from '@qwen-code/qwen-code-core/goals/goal-runtime.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { theme } from '../semantic-colors.js';
Expand Down Expand Up @@ -98,13 +101,24 @@ function presentation(snapshot: GoalSnapshotV2): {
if (!goal || goal.status === 'complete') return null;

if (goal.status === 'active') {
return snapshot.activity === 'verifying'
? {
icon: ICON.CIRCLE_EMPTY,
label: 'checking',
color: theme.text.secondary,
}
: { icon: ICON.BULLSEYE, label: 'active', color: theme.text.accent };
if (snapshot.activity === 'verifying') {
return {
icon: ICON.CIRCLE_EMPTY,
label: 'checking',
color: theme.text.secondary,
};
}
// A Goal paying a failed checkpoint every turn otherwise looks exactly
// like one that is working, until the stall breaker stops it.
const stalls = goal.checkpointStalls ?? 0;
if (stalls > 0) {
return {
icon: '!',
label: `checkpoint ${stalls}/${GOAL_CHECKPOINT_STALL_LIMIT} stalled`,
color: theme.status.warning,
};
}
return { icon: ICON.BULLSEYE, label: 'active', color: theme.text.accent };
}
switch (goal.status) {
case 'paused':
Expand Down
140 changes: 140 additions & 0 deletions packages/cli/src/ui/components/messages/GoalStatusMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,146 @@ describe('<GoalStatusMessage />', () => {
expect(lastFrame()).not.toContain('tokens');
});

it('shows stalled checkpoints and the last failure on an active card', () => {
const { lastFrame } = render(
<GoalStatusMessage
snapshot={snapshot('active', 'running', undefined, {
checkpointStalls: 2,
lastCheckpointFailure: 'Error: provider failed',
})}
/>,
);

expect(lastFrame()).toContain(
'Checkpoint: 2/3 stalled · Error: provider failed',
);
});

it('shows a checkpoint failure that spent no stall', () => {
const { lastFrame } = render(
<GoalStatusMessage
snapshot={snapshot('active', 'running', undefined, {
lastCheckpointFailure: 'Error: provider failed',
})}
/>,
);

expect(lastFrame()).toContain(
'Checkpoint: last check failed · Error: provider failed',
);
});

it('keeps the failure on the card of a Goal the stall breaker stopped', () => {
// The stop reason names the kind of failure; only this line says which.
const { lastFrame } = render(
<GoalStatusMessage
snapshot={snapshot('usage_limited', 'idle', 'checkpoints stalled', {
checkpointStalls: 3,
lastCheckpointFailure: 'Error: provider failed',
})}
/>,
);

expect(lastFrame()).toContain('Reason: checkpoints stalled');
expect(lastFrame()).toContain(
'Checkpoint: 3/3 stalled · Error: provider failed',
);
});

it('hides checkpoint health on a completed Goal that still carries it', () => {
// The terminal snapshot spreads the record and overrides only `status`,
// so a Goal that completed after a failed check journals both fields.
const { lastFrame } = render(
<GoalStatusMessage
snapshot={snapshot('complete', 'idle', 'all acceptance checks passed', {
checkpointStalls: 1,
lastCheckpointFailure: 'Error: provider failed',
})}
/>,
);

expect(lastFrame()).toContain('Goal complete');
expect(lastFrame()).not.toContain('Checkpoint');
});

it('hides a stall-free failure once the Goal stops for another reason', () => {
const paused = render(
<GoalStatusMessage
snapshot={snapshot('paused', 'idle', 'no progress in three turns', {
lastCheckpointFailure: 'Error: provider failed',
})}
/>,
);
expect(paused.lastFrame()).not.toContain('Checkpoint');

// A running streak is still the truth about the window a resume re-enters.
const streak = render(
<GoalStatusMessage
snapshot={snapshot('paused', 'idle', 'paused by the user', {
checkpointStalls: 2,
lastCheckpointFailure: 'Error: provider failed',
})}
/>,
);
expect(streak.lastFrame()).toContain('Checkpoint: 2/3 stalled');
});

it('shows a bare stall streak without a trailing separator', () => {
// A stop for another reason clears the diagnostic and keeps the streak.
const { lastFrame } = render(
<GoalStatusMessage
snapshot={snapshot('paused', 'idle', 'paused by the user', {
checkpointStalls: 2,
})}
/>,
);

expect(lastFrame()).toContain('Checkpoint: 2/3 stalled');
expect(lastFrame()).not.toContain('stalled ·');
});

it('never writes control or bidi characters from the diagnostic to the terminal', () => {
const { lastFrame } = render(
<GoalStatusMessage
snapshot={snapshot('active', 'running', undefined, {
checkpointStalls: 1,
lastCheckpointFailure: 'stalled\rGoal complete \u202efailed',
})}
/>,
);

const frame = lastFrame() ?? '';
expect(frame).toContain('Checkpoint: 1/3 stalled');
expect(frame).not.toContain('\r');
expect(frame).not.toContain('\u202e');
});

it('never writes control characters from a stop reason to the terminal', () => {
// A pause reason can embed a raw provider error.
const { lastFrame } = render(
<GoalStatusMessage
snapshot={snapshot(
'paused',
'idle',
'paused\r\u001b]52;c;ZXh0cmFjdGVk\u0007 by user',
)}
/>,
);

const frame = lastFrame() ?? '';
expect(frame).toContain('Reason: paused');
expect(frame).not.toContain('\r');
expect(frame).not.toContain('\u0007');
});

it('says nothing about checkpoints on a healthy card', () => {
const { lastFrame } = render(
<GoalStatusMessage snapshot={snapshot('active', 'running')} />,
);

expect(lastFrame()).not.toContain('Checkpoint');
});

it('leaves the legacy card without spend it cannot know', () => {
// The legacy props carry an iteration count and nothing else; there is no
// record behind them to read a spend off.
Expand Down
Loading
Loading