diff --git a/.changeset/goal-mode-outcomes.md b/.changeset/goal-mode-outcomes.md new file mode 100644 index 000000000..1e9bfca79 --- /dev/null +++ b/.changeset/goal-mode-outcomes.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Improve goal mode outcome handling with follow-up messages, safer error pauses, and clearer TUI transcript display. diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index 79c7efdd4..b2b83e81e 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -460,7 +460,6 @@ async function resumeGoal(host: SlashCommandHost): Promise { return; } host.track('goal_resume'); - host.showStatus('Goal resumed.'); host.sendNormalUserInput(RESUME_GOAL_INPUT); } @@ -478,7 +477,7 @@ async function cancelGoal(host: SlashCommandHost): Promise { return; } host.track('goal_cancel'); - host.showStatus('Goal cancelled.'); + host.showNotice('Goal cancelled.'); } async function showGoalStatus(host: SlashCommandHost): Promise { diff --git a/apps/kimi-code/src/tui/components/messages/goal-format.ts b/apps/kimi-code/src/tui/components/messages/goal-format.ts new file mode 100644 index 000000000..2a5933425 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/goal-format.ts @@ -0,0 +1,13 @@ +export function formatGoalElapsed(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${String(totalSeconds)}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return `${String(minutes)}m ${seconds.toString().padStart(2, '0')}s`; + const hours = Math.floor(minutes / 60); + return `${String(hours)}h ${(minutes % 60).toString().padStart(2, '0')}m`; +} + +export function pluralizeGoalCount(n: number, singular: string, plural?: string): string { + return `${String(n)} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; +} diff --git a/apps/kimi-code/src/tui/components/messages/goal-markers.ts b/apps/kimi-code/src/tui/components/messages/goal-markers.ts index 3a02c18f7..97bd2c2c8 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -11,20 +11,43 @@ import type { Component } from '@earendil-works/pi-tui'; import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; import chalk from 'chalk'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; const HEAD_INDENT = ' '; const DETAIL_INDENT = ' '; +type GoalMarkerActor = 'user' | 'model' | 'runtime' | 'system'; + +interface GoalMarkerOptions { + readonly marker?: string; + readonly textHex?: string; + readonly expandable?: boolean; + readonly indent?: string; + readonly leadingBlank?: boolean; +} + export class GoalMarkerComponent implements Component { private expanded = false; + private readonly marker: string; + private readonly textHex: string; + private readonly expandable: boolean; + private readonly indent: string; + private readonly leadingBlank: boolean; constructor( private readonly headline: string, private readonly detail: string | undefined, private readonly colors: ColorPalette, private readonly accentHex: string, - ) {} + options: GoalMarkerOptions = {}, + ) { + this.marker = options.marker ?? '◦'; + this.textHex = options.textHex ?? colors.textDim; + this.expandable = options.expandable ?? true; + this.indent = options.indent ?? HEAD_INDENT; + this.leadingBlank = options.leadingBlank ?? false; + } invalidate(): void {} @@ -33,20 +56,29 @@ export class GoalMarkerComponent implements Component { } render(width: number): string[] { - const dot = chalk.hex(this.accentHex)('◦'); - const head = chalk.hex(this.colors.textDim)(this.headline); + const dot = chalk.hex(this.accentHex)(this.marker); + const head = chalk.hex(this.textHex)(this.headline); const hasDetail = this.detail !== undefined && this.detail.length > 0; - if (!hasDetail) return [`${HEAD_INDENT}${dot} ${head}`]; + if (!hasDetail) return this.withLeadingBlank([`${this.indent}${dot} ${head}`]); + if (!this.expandable) { + return this.withLeadingBlank([`${this.indent}${dot} ${head}`]); + } if (!this.expanded) { - return [`${HEAD_INDENT}${dot} ${head} ${chalk.hex(this.colors.textMuted)('(ctrl+o)')}`]; + return this.withLeadingBlank([ + `${this.indent}${dot} ${head} ${chalk.hex(this.colors.textMuted)('(ctrl+o)')}`, + ]); } - const out = [`${HEAD_INDENT}${dot} ${head}`]; + const out = [`${this.indent}${dot} ${head}`]; const wrapWidth = Math.max(20, width - DETAIL_INDENT.length); for (const line of wrap(this.detail!, wrapWidth)) { out.push(DETAIL_INDENT + chalk.hex(this.colors.textDim)(line)); } - return out; + return this.withLeadingBlank(out); + } + + private withLeadingBlank(lines: string[]): string[] { + return this.leadingBlank ? ['', ...lines] : lines; } } @@ -59,10 +91,17 @@ export function buildGoalMarker( change: GoalChange, colors: ColorPalette, expanded: boolean, + actor?: GoalMarkerActor, ): GoalMarkerComponent | null { - const spec = markerSpec(change, colors); + const spec = markerSpec(change, colors, actor); if (spec === null) return null; - const marker = new GoalMarkerComponent(spec.headline, change.reason, colors, spec.accentHex); + const marker = new GoalMarkerComponent( + spec.headline, + spec.detail ?? change.reason, + colors, + spec.accentHex, + spec.options, + ); marker.setExpanded(expanded); return marker; } @@ -70,13 +109,19 @@ export function buildGoalMarker( function markerSpec( change: GoalChange, colors: ColorPalette, -): { headline: string; accentHex: string } | null { + actor?: GoalMarkerActor, +): { + headline: string; + accentHex: string; + detail?: string | undefined; + options?: GoalMarkerOptions | undefined; +} | null { if (change.kind === 'lifecycle') { switch (change.status) { case 'paused': - return { headline: 'Goal paused', accentHex: colors.textDim }; + return prominentMarker(pausedHeadline(change.reason, actor), colors.warning); case 'active': - return { headline: 'Goal resumed', accentHex: colors.primary }; + return prominentMarker(resumedHeadline(actor), colors.primary); case 'blocked': // The system stopped pursuing the goal; resumable via `/goal resume`. return { headline: 'Goal blocked', accentHex: colors.warning }; @@ -87,6 +132,40 @@ function markerSpec( return null; // completion -> posts its own message, not a marker } +function prominentMarker(headline: string, accentHex: string) { + return { + headline, + accentHex, + detail: undefined, + options: { + marker: STATUS_BULLET.trimEnd(), + textHex: accentHex, + expandable: false, + indent: '', + leadingBlank: true, + }, + }; +} + +function pausedHeadline(reason: string | undefined, actor: GoalMarkerActor | undefined): string { + if (reason === 'Paused after interruption') return "Goal paused due to user's interruption"; + if (actor === 'user') return 'Goal paused by the user.'; + if (reason?.startsWith('Paused ') === true) return `Goal ${lowercaseFirst(reason)}`; + if (reason !== undefined && reason.length > 0) return `Goal paused: ${reason}`; + if (actor === 'model') return 'Goal paused by the agent.'; + return 'Goal paused'; +} + +function resumedHeadline(actor: GoalMarkerActor | undefined): string { + if (actor === 'user') return 'Goal resumed by the user.'; + if (actor === 'model') return 'Goal resumed by the agent.'; + return 'Goal resumed'; +} + +function lowercaseFirst(text: string): string { + return text.length === 0 ? text : `${text[0]!.toLowerCase()}${text.slice(1)}`; +} + function wrap(text: string, width: number): string[] { const words = text.replace(/\s+/g, ' ').trim().split(' '); const lines: string[] = []; diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts index f5914e779..48be7b456 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -22,6 +22,7 @@ import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; import { formatTokenCount } from '#/utils/usage/usage-format'; +import { formatGoalElapsed } from './goal-format'; import { UsagePanelComponent } from './usage-panel'; const WRAP_WIDTH = 72; @@ -161,7 +162,7 @@ export function buildGoalReportLines(options: GoalReportOptions): string[] { ), ); } - lines.push(row('Running', value(formatElapsed(goal.wallClockMs)))); + lines.push(row('Running', value(formatGoalElapsed(goal.wallClockMs)))); lines.push(row('Turns', value(`${goal.turnsUsed}`))); lines.push(row('Tokens', value(formatTokenCount(goal.tokensUsed)))); if (!isComplete) { @@ -186,7 +187,7 @@ function formatStopRow(goal: GoalSnapshot): string | null { parts.push(`at ${formatTokenCount(budget.tokenBudget)} tokens`); } if (budget.wallClockBudgetMs !== null) { - parts.push(`after ${formatElapsed(budget.wallClockBudgetMs)}`); + parts.push(`after ${formatGoalElapsed(budget.wallClockBudgetMs)}`); } return parts.length > 0 ? parts.join(', ') : null; } @@ -204,16 +205,6 @@ function statusHex(status: GoalStatus, colors: ColorPalette): string { } } -function formatElapsed(ms: number): string { - const totalSeconds = Math.round(ms / 1000); - if (totalSeconds < 60) return `${totalSeconds}s`; - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - if (minutes < 60) return `${minutes}m ${seconds.toString().padStart(2, '0')}s`; - const hours = Math.floor(minutes / 60); - return `${hours}h ${(minutes % 60).toString().padStart(2, '0')}m`; -} - /** Word-wrap to `width`, capped at `maxLines` (last line gets an ellipsis when clipped). */ function wrap(text: string, width: number, maxLines: number): string[] { const words = text.replaceAll(/\s+/g, ' ').trim().split(' '); diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index d098fdcd6..c0c8e8999 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -31,6 +31,7 @@ import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress'; import { PlanBoxComponent } from './plan-box'; import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; +import { buildGoalToolHeader } from './tool-renderers/goal'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; import { TruncatedOutputComponent } from './tool-renderers/truncated'; @@ -1254,6 +1255,15 @@ export class ToolCallComponent extends Container { return `${bullet}${tone.bold(label)}`; } + const goalHeader = buildGoalToolHeader({ + toolCall, + result, + colors, + bullet, + chip: isFinished && result !== undefined ? this.buildHeaderChip(result) : '', + }); + if (goalHeader !== undefined) return goalHeader; + if (this.isSingleSubagentView()) { return this.buildSingleSubagentHeader(); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index a0200a761..c7c8120f2 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -11,6 +11,7 @@ import { computeDiffLines } from '#/tui/components/media/diff-preview'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; +import { goalStatusChip } from './goal'; import { readMediaChip } from './media'; import { strArg } from './types'; @@ -110,6 +111,9 @@ const webSearchChip: ChipProvider = (_toolCall, result) => { return pluralize(count, 'result'); }; +const goalStatusOutputChip: ChipProvider = (_toolCall, result) => + result.is_error ? '' : goalStatusChip(result.output); + const REGISTRY: Record = { Edit: editChip, Write: writeChip, @@ -119,6 +123,8 @@ const REGISTRY: Record = { Glob: globChip, FetchURL: fetchChip, WebSearch: webSearchChip, + CreateGoal: goalStatusOutputChip, + GetGoal: goalStatusOutputChip, }; export function pickChip(toolName: string): ChipProvider | undefined { diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts new file mode 100644 index 000000000..bcf1e72bf --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -0,0 +1,226 @@ +import { Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { ColorPalette } from '#/tui/theme/colors'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; +import { formatTokenCount } from '#/utils/usage/usage-format'; + +import { formatGoalElapsed, pluralizeGoalCount } from '../goal-format'; +import { renderTruncated } from './truncated'; +import type { ResultRenderer } from './types'; + +type GoalToolName = 'CreateGoal' | 'GetGoal' | 'SetGoalBudget' | 'UpdateGoal'; + +interface GoalSnapshotView { + readonly objective: string; + readonly status: string; + readonly turnsUsed: number; + readonly tokensUsed: number; + readonly wallClockMs: number; + readonly terminalReason?: string | undefined; +} + +const GOAL_TOOLS = new Set([ + 'CreateGoal', + 'GetGoal', + 'SetGoalBudget', + 'UpdateGoal', +]); + +export function isGoalToolName(toolName: string): toolName is GoalToolName { + return GOAL_TOOLS.has(toolName); +} + +export const goalSummary: ResultRenderer = (toolCall, result, ctx) => { + if (result.is_error) return renderTruncated(toolCall, result, ctx); + + switch (toolCall.name) { + case 'CreateGoal': + case 'GetGoal': + return renderGoalSnapshot(toolCall, result, ctx); + case 'SetGoalBudget': + case 'UpdateGoal': + return []; + default: + return renderTruncated(toolCall, result, ctx); + } +}; + +export function buildGoalToolHeader(options: { + readonly toolCall: ToolCallBlockData; + readonly result: ToolResultBlockData | undefined; + readonly colors: ColorPalette; + readonly bullet: string; + readonly chip: string; +}): string | undefined { + const { toolCall, result, colors, bullet, chip } = options; + if (!isGoalToolName(toolCall.name)) return undefined; + + const tone = result?.is_error === true ? colors.error : colors.primary; + const label = chalk.hex(tone).bold(goalToolLabel(toolCall.name, result, toolCall.args)); + const marker = + result !== undefined && result.is_error !== true + ? chalk.hex(colors.primary)(STATUS_BULLET) + : bullet; + const arg = + toolCall.name === 'UpdateGoal' + ? undefined + : formatGoalToolArgument(toolCall.name, toolCall.args); + const argText = arg === undefined ? '' : chalk.hex(colors.textDim)(` (${arg})`); + return `${marker}${label}${argText}${chip}`; +} + +function formatGoalBudgetArg(args: Record): string | undefined { + const value = args['value']; + const unit = args['unit']; + if (typeof value !== 'number' || !Number.isFinite(value) || typeof unit !== 'string') { + return undefined; + } + if (unit.length === 0) return undefined; + const normalized = unit === 'turns' || unit === 'tokens' + ? Math.max(1, Math.round(value)) + : value; + const singular = unit.endsWith('s') ? unit.slice(0, -1) : unit; + return `${String(normalized)} ${normalized === 1 ? singular : unit}`; +} + +export function goalStatusChip(output: string): string { + const goal = parseGoalValue(output); + if (goal === undefined) return ''; + if (goal === null) return 'no goal'; + return stringField(goal, 'status') ?? ''; +} + +function renderGoalSnapshot( + toolCall: ToolCallBlockData, + result: ToolResultBlockData, + ctx: Parameters[2], +) { + const goal = parseGoalToolOutput(result.output); + if (goal === undefined) return renderTruncated(toolCall, result, ctx); + + const muted = chalk.hex(ctx.colors.textDim); + const value = chalk.hex(ctx.colors.text); + if (goal === null) return [new Text(muted(' No current goal.'), 0, 0)]; + + const lines = [ + ` ${value(`Goal ${goal.status}: ${truncateOneLine(goal.objective, 96)}`)}`, + ` ${muted(formatGoalStats(goal))}`, + ]; + if (goal.terminalReason !== undefined && goal.terminalReason.length > 0) { + lines.push(` ${muted(goal.terminalReason)}`); + } + return lines.map((line) => new Text(line, 0, 0)); +} + +function goalToolLabel( + toolName: GoalToolName, + result: ToolResultBlockData | undefined, + args: Record, +): string { + const failed = result?.is_error === true; + const finished = result !== undefined; + switch (toolName) { + case 'CreateGoal': + return failed ? 'Could not start goal' : finished ? 'Started goal' : 'Starting goal'; + case 'GetGoal': + return failed ? 'Could not check goal' : finished ? 'Checked goal' : 'Checking goal'; + case 'SetGoalBudget': + return failed + ? 'Could not set goal budget' + : finished + ? 'Set goal budget' + : 'Setting goal budget'; + case 'UpdateGoal': { + const status = stringArg(args, 'status'); + const suffix = status ?? 'status'; + return failed + ? `Could not report goal ${suffix}` + : finished + ? `Reported goal ${suffix}` + : `Reporting goal ${suffix}`; + } + } +} + +function formatGoalToolArgument( + toolName: GoalToolName, + args: Record, +): string | undefined { + switch (toolName) { + case 'CreateGoal': { + const objective = stringArg(args, 'objective'); + return objective === undefined ? undefined : truncateOneLine(objective, 60); + } + case 'SetGoalBudget': + return formatGoalBudgetArg(args); + case 'UpdateGoal': + return stringArg(args, 'status'); + case 'GetGoal': + return undefined; + } +} + +function parseGoalToolOutput(output: string): GoalSnapshotView | null | undefined { + const goal = parseGoalValue(output); + if (goal === undefined || goal === null) return goal; + const objective = stringField(goal, 'objective'); + const status = stringField(goal, 'status'); + if (objective === undefined || status === undefined) return undefined; + return { + objective, + status, + turnsUsed: numberField(goal, 'turnsUsed'), + tokensUsed: numberField(goal, 'tokensUsed'), + wallClockMs: numberField(goal, 'wallClockMs'), + terminalReason: stringField(goal, 'terminalReason'), + }; +} + +function parseGoalValue(output: string): Record | null | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return undefined; + } + if (!isRecord(parsed) || !('goal' in parsed)) return undefined; + const goal = parsed['goal']; + if (goal === null) return null; + if (!isRecord(goal)) return undefined; + return goal; +} + +function formatGoalStats(goal: GoalSnapshotView): string { + return [ + pluralizeGoalCount(goal.turnsUsed, 'turn'), + `${formatTokenCount(goal.tokensUsed)} tokens`, + formatGoalElapsed(goal.wallClockMs), + ].join(' · '); +} + +function truncateOneLine(text: string, max: number): string { + const firstLine = text.replaceAll(/\s+/g, ' ').trim(); + if (firstLine.length <= max) return firstLine; + return `${firstLine.slice(0, Math.max(0, max - 1))}…`; +} + +function stringArg(args: Record, key: string): string | undefined { + const value = args[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' ? value : undefined; +} + +function numberField(record: Record, key: string): number { + const value = record[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts index 577281774..2a7b39539 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts @@ -12,6 +12,7 @@ import { readMediaSummary } from './media'; import { shellExecutionResultRenderer } from '../shell-execution'; +import { goalSummary } from './goal'; import { editSummary, fetchSummary, @@ -57,6 +58,11 @@ export function pickResultRenderer(toolName: string): ResultRenderer { return editSummary; case 'Write': return writeSummary; + case 'CreateGoal': + case 'GetGoal': + case 'SetGoalBudget': + case 'UpdateGoal': + return goalSummary; default: return renderTruncated; } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 1eec940c4..a6d5ed013 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -12,6 +12,7 @@ import type { CronFiredEvent, ErrorEvent, Event, + GoalChange, GoalUpdatedEvent, HookResultEvent, Session, @@ -135,6 +136,8 @@ export class SessionEventHandler { mcpServers: Map = new Map(); private goalCompletionAwaitingClear = false; private goalCompletionTurnEnded = false; + private currentTurnHasAssistantText = false; + private pendingModelBlockedFallback: GoalChange | undefined; private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; @@ -148,6 +151,8 @@ export class SessionEventHandler { this.mcpServers.clear(); this.goalCompletionAwaitingClear = false; this.goalCompletionTurnEnded = false; + this.currentTurnHasAssistantText = false; + this.pendingModelBlockedFallback = undefined; this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); @@ -281,6 +286,7 @@ export class SessionEventHandler { private handleTurnBegin(_event: TurnStartedEvent): void { void _event; + this.currentTurnHasAssistantText = false; this.clearAgentSwarmProgress(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.setStep(0); @@ -324,6 +330,8 @@ export class SessionEventHandler { } this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeTurn(sendQueued); + this.renderPendingModelBlockedFallback(); + this.currentTurnHasAssistantText = false; this.goalCompletionTurnEnded = true; this.scheduleQueuedGoalPromotion(); } @@ -415,6 +423,10 @@ export class SessionEventHandler { streamingUI.flushThinkingToTranscript('idle'); } + if (event.delta.trim().length > 0) { + this.currentTurnHasAssistantText = true; + this.pendingModelBlockedFallback = undefined; + } streamingUI.appendAssistantDelta(event.delta); this.host.patchLivePane({ @@ -434,6 +446,10 @@ export class SessionEventHandler { this.host.streamingUI.flushThinkingToTranscript('idle'); } this.host.streamingUI.finalizeAssistantStream(); + if (event.content.trim().length > 0) { + this.currentTurnHasAssistantText = true; + this.pendingModelBlockedFallback = undefined; + } this.host.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'assistant', @@ -573,6 +589,9 @@ export class SessionEventHandler { this.queuedGoalPromotionPending = true; this.scheduleQueuedGoalPromotion(); } + if (event.snapshot === null) { + this.pendingModelBlockedFallback = undefined; + } const change = event.change; if (change === undefined) return; const { state } = this.host; @@ -582,6 +601,7 @@ export class SessionEventHandler { // Resume renders the same text from the durable goal completion replay // record, so live and replayed completion cards stay identical. if (change.kind === 'completion' && event.snapshot !== null) { + this.pendingModelBlockedFallback = undefined; this.goalCompletionAwaitingClear = true; this.goalCompletionTurnEnded = false; this.host.appendTranscriptEntry({ @@ -598,8 +618,39 @@ export class SessionEventHandler { // ctrl+o-expandable marker. if (change.kind === 'lifecycle' && change.status === 'blocked') { void this.notifyQueuedGoalWaitingOnBlocked(); + if (change.actor === 'model' || change.reason === undefined) { + this.pendingModelBlockedFallback = this.currentTurnHasAssistantText + ? undefined + : change; + return; + } + this.pendingModelBlockedFallback = undefined; + } else if (change.kind === 'lifecycle') { + this.pendingModelBlockedFallback = undefined; } - const marker = buildGoalMarker(change, state.theme.colors, state.toolOutputExpanded); + const marker = buildGoalMarker( + change, + state.theme.colors, + state.toolOutputExpanded, + change.actor, + ); + if (marker !== null) { + state.transcriptContainer.addChild(marker); + state.ui.requestRender(); + } + } + + private renderPendingModelBlockedFallback(): void { + const change = this.pendingModelBlockedFallback; + if (change === undefined) return; + this.pendingModelBlockedFallback = undefined; + const { state } = this.host; + const marker = buildGoalMarker( + change, + state.theme.colors, + state.toolOutputExpanded, + 'model', + ); if (marker !== null) { state.transcriptContainer.addChild(marker); state.ui.requestRender(); diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 8f68a7039..b7d761346 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -256,7 +256,14 @@ export class SessionReplayRenderer { if (isGoalForkClearedSystemReminder(message)) { return; } - if (isGoalCompletionSystemReminder(message)) { + const goalReminder = goalOutcomeReminderFromSystemMessage(message); + if (goalReminder !== null) { + if (goalReminder !== undefined) { + this.flushAssistant(context); + this.host.appendTranscriptEntry( + replayEntry(context, 'assistant', goalReminder, 'markdown'), + ); + } return; } @@ -384,15 +391,25 @@ export class SessionReplayRenderer { case 'lifecycle': { const lifecycleChange: GoalReplayLifecycleChange = { ...change, kind: 'lifecycle' }; if (isResumeNormalizationGoalPause(lifecycleChange)) return; - this.host.appendTranscriptEntry({ - ...replayEntry(context, 'goal', goalLifecycleReplayContent(lifecycleChange), 'plain'), - goalData: { kind: 'lifecycle', change: lifecycleChange }, - }); + if (isModelBlockedGoalLifecycle(lifecycleChange)) { + return; + } + this.appendGoalLifecycleReplayEntry(context, lifecycleChange); return; } } } + private appendGoalLifecycleReplayEntry( + context: ReplayRenderContext, + change: GoalReplayLifecycleChange, + ): void { + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'goal', goalLifecycleReplayContent(change), 'plain'), + goalData: { kind: 'lifecycle', change }, + }); + } + private renderHookResult(context: ReplayRenderContext, message: ContextMessage): void { if (message.origin?.kind !== 'hook_result') return; this.flushAssistant(context); @@ -613,8 +630,16 @@ function goalLifecycleReplayContent(change: GoalReplayLifecycleChange): string { } } -function isGoalCompletionSystemReminder(message: ContextMessage): boolean { - return message.origin?.kind === 'system_trigger' && message.origin.name === 'goal_completion'; +function isModelBlockedGoalLifecycle(change: GoalReplayLifecycleChange): boolean { + return change.status === 'blocked' && change.actor === 'model'; +} + +function goalOutcomeReminderFromSystemMessage(message: ContextMessage): string | undefined | null { + if (message.origin?.kind !== 'system_trigger') return null; + if (message.origin.name !== 'goal_completion' && message.origin.name !== 'goal_blocked') { + return null; + } + return undefined; } function isGoalForkClearedSystemReminder(message: ContextMessage): boolean { diff --git a/apps/kimi-code/src/tui/utils/goal-completion.ts b/apps/kimi-code/src/tui/utils/goal-completion.ts index 26963797d..f4c802b4a 100644 --- a/apps/kimi-code/src/tui/utils/goal-completion.ts +++ b/apps/kimi-code/src/tui/utils/goal-completion.ts @@ -1,11 +1,22 @@ import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk'; +interface GoalCompletionStats { + readonly terminalReason?: string | undefined; + readonly turnsUsed: number; + readonly tokensUsed: number; + readonly wallClockMs: number; +} + /** * Deterministic goal-completion text rendered by the TUI when the model marks a * goal `complete`. It is built from the final snapshot, so the figures * (turns / tokens / time) are exact and do not depend on model prose. */ export function buildGoalCompletionMessage(goal: GoalSnapshot): string { + return buildGoalCompletionMessageFromStats(goal); +} + +export function buildGoalCompletionMessageFromStats(goal: GoalCompletionStats): string { const head = `✓ Goal complete${goal.terminalReason ? ` — ${goal.terminalReason}` : ''}.`; const turns = `${goal.turnsUsed} turn${goal.turnsUsed === 1 ? '' : 's'}`; const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokens(goal.tokensUsed)} tokens.`; diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index 79fe92f18..3571a94c6 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -612,6 +612,7 @@ describe('handleGoalCommand', () => { await handleGoalCommand(host, 'resume'); expect(session.resumeGoal).toHaveBeenCalledOnce(); expect(host.track).toHaveBeenCalledWith('goal_resume'); + expect(host.showStatus).not.toHaveBeenCalledWith('Goal resumed.'); expect(host.sendNormalUserInput).toHaveBeenCalledWith('Resume the active goal.'); }); @@ -619,6 +620,8 @@ describe('handleGoalCommand', () => { await handleGoalCommand(host, 'cancel'); expect(session.cancelGoal).toHaveBeenCalledOnce(); expect(host.track).toHaveBeenCalledWith('goal_cancel'); + expect(host.showNotice).toHaveBeenCalledWith('Goal cancelled.'); + expect(host.showStatus).not.toHaveBeenCalledWith('Goal cancelled.'); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); }); diff --git a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts index 05d919180..66435dcc8 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts @@ -19,6 +19,56 @@ describe('buildGoalMarker', () => { expect(strip(blocked!.render(80))).toContain('Goal blocked'); }); + it('renders user interruption pause and user resume as prominent markers', () => { + const paused = buildGoalMarker( + { kind: 'lifecycle', status: 'paused', reason: 'Paused after interruption' } as GoalChange, + darkColors, + false, + 'runtime', + ); + const resumed = buildGoalMarker( + { kind: 'lifecycle', status: 'active' } as GoalChange, + darkColors, + false, + 'user', + ); + + expect(strip(paused!.render(80))).toBe("\n● Goal paused due to user's interruption"); + expect(strip(resumed!.render(80))).toBe('\n● Goal resumed by the user.'); + expect(strip([...paused!.render(80), ...resumed!.render(80)])).toBe( + "\n● Goal paused due to user's interruption\n\n● Goal resumed by the user.", + ); + }); + + it('does not repeat paused for runtime pause reasons', () => { + const marker = buildGoalMarker( + { kind: 'lifecycle', status: 'paused', reason: 'Paused after runtime error: socket hang up' } as GoalChange, + darkColors, + false, + 'runtime', + ); + + expect(strip(marker!.render(80))).toBe('\n● Goal paused after runtime error: socket hang up'); + }); + + it('attributes model pause and resume markers to the agent', () => { + const paused = buildGoalMarker( + { kind: 'lifecycle', status: 'paused' } as GoalChange, + darkColors, + false, + 'model', + ); + const resumed = buildGoalMarker( + { kind: 'lifecycle', status: 'active' } as GoalChange, + darkColors, + false, + 'model', + ); + + expect(strip(paused!.render(80))).toBe('\n● Goal paused by the agent.'); + expect(strip(resumed!.render(80))).toBe('\n● Goal resumed by the agent.'); + }); + it('returns null for a completion change (it posts its own message)', () => { expect( buildGoalMarker({ kind: 'completion', status: 'complete' } as GoalChange, darkColors, false), diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index dbac42876..4d54cea6d 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -1,4 +1,5 @@ import type { TUI } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; @@ -486,6 +487,152 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('Collected your answers'); }); + it('renders GetGoal as a goal check without raw JSON', () => { + const component = new ToolCallComponent( + { + id: 'call_get_goal', + name: 'GetGoal', + args: {}, + }, + { + tool_call_id: 'call_get_goal', + output: JSON.stringify({ + goal: { + goalId: 'g1', + objective: 'Ship feature X', + status: 'active', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + startedBy: 'model', + updatedBy: 'model', + turnsUsed: 1, + tokensUsed: 800, + wallClockMs: 5000, + budget: { + tokenBudget: null, + turnBudget: null, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: null, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + }, + }), + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Checked goal'); + expect(out).toContain('Goal active: Ship feature X'); + expect(out).not.toContain('Used GetGoal'); + expect(out).not.toContain('"objective"'); + }); + + it('renders SetGoalBudget with a readable budget argument', () => { + const component = new ToolCallComponent( + { + id: 'call_goal_budget', + name: 'SetGoalBudget', + args: { value: 10, unit: 'turns' }, + }, + { + tool_call_id: 'call_goal_budget', + output: 'Goal budget set: 10 turns.', + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Set goal budget (10 turns)'); + expect(out).not.toContain('Set goal budget (10 turns) · 10 turns'); + expect(out).not.toContain('Used SetGoalBudget (turns)'); + expect(out).not.toContain('Goal budget set: 10 turns.'); + }); + + it('renders successful SetGoalBudget headers with the primary goal marker', () => { + const previousLevel = chalk.level; + chalk.level = 3; + try { + const component = new ToolCallComponent( + { + id: 'call_goal_budget', + name: 'SetGoalBudget', + args: { value: 10, unit: 'turns' }, + }, + { + tool_call_id: 'call_goal_budget', + output: 'Goal budget set: 10 turns.', + is_error: false, + }, + darkColors, + ); + + const out = component.render(100).join('\n'); + expect(out).toContain(chalk.hex(darkColors.primary)(STATUS_BULLET)); + expect(out).not.toContain(chalk.hex(darkColors.success)(STATUS_BULLET)); + } finally { + chalk.level = previousLevel; + } + }); + + it('renders UpdateGoal as a model-reported status, not a user lifecycle marker', () => { + const component = new ToolCallComponent( + { + id: 'call_update_goal', + name: 'UpdateGoal', + args: { status: 'blocked' }, + }, + { + tool_call_id: 'call_update_goal', + output: 'Goal marked blocked.', + is_error: false, + }, + darkColors, + ); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Reported goal blocked'); + expect(out).not.toContain('Updated goal (blocked)'); + expect(out).not.toContain('· blocked'); + expect(out).not.toContain('Goal marked blocked.'); + expect(out).not.toContain('● Goal blocked'); + }); + + it('renders successful UpdateGoal report headers entirely in the primary goal color', () => { + const previousLevel = chalk.level; + chalk.level = 3; + try { + for (const status of ['complete', 'blocked']) { + const component = new ToolCallComponent( + { + id: `call_update_goal_${status}`, + name: 'UpdateGoal', + args: { status }, + }, + { + tool_call_id: `call_update_goal_${status}`, + output: `Goal marked ${status}.`, + is_error: false, + }, + darkColors, + ); + + const out = component.render(100).join('\n'); + expect(out).toContain(chalk.hex(darkColors.primary)(STATUS_BULLET)); + expect(out).not.toContain(chalk.hex(darkColors.success)(STATUS_BULLET)); + } + } finally { + chalk.level = previousLevel; + } + }); + it('appends a chip to the header once a result arrives', () => { const component = new ToolCallComponent( { diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts index 99d253af7..7942cdae8 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -80,6 +80,26 @@ describe('chip registry', () => { expect(pickChip('Think')).toBeUndefined(); }); + it('GetGoal chip shows the current status', () => { + expect(chipFor('GetGoal', {}, result('{"goal":{"status":"active"}}'))).toBe('active'); + }); + + it('GetGoal chip shows when there is no current goal', () => { + expect(chipFor('GetGoal', {}, result('{"goal":null}'))).toBe('no goal'); + }); + + it('CreateGoal chip shows the created status', () => { + expect(chipFor('CreateGoal', { objective: 'Ship feature X' }, result('{"goal":{"status":"active"}}'))).toBe('active'); + }); + + it('SetGoalBudget has no chip because the budget is in the header argument', () => { + expect(pickChip('SetGoalBudget')).toBeUndefined(); + }); + + it('UpdateGoal has no chip because the status is in the header label', () => { + expect(pickChip('UpdateGoal')).toBeUndefined(); + }); + it('Unknown tools have no chip', () => { expect(pickChip('SomethingElse')).toBeUndefined(); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 4252e965c..7dcd55bed 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -27,6 +27,36 @@ function result(output: string, isError = false): ToolResultBlockData { const ctx = { expanded: false, colors: darkColors }; const expandedCtx = { expanded: true, colors: darkColors }; +function goalOutput(overrides: Record = {}): string { + return JSON.stringify({ + goal: { + goalId: 'g1', + objective: 'Ship feature X', + status: 'active', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + startedBy: 'model', + updatedBy: 'model', + turnsUsed: 2, + tokensUsed: 1234, + wallClockMs: 61000, + budget: { + tokenBudget: null, + turnBudget: null, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: null, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + ...overrides, + }, + }); +} + describe('tool-result registry', () => { it('falls back to truncated renderer for unknown tools', () => { const renderer = pickResultRenderer('SomethingUnknown'); @@ -156,6 +186,43 @@ describe('tool-result registry', () => { expect(out.trim()).toBe(''); }); + it('GetGoal renders a compact goal summary instead of raw JSON', () => { + const renderer = pickResultRenderer('GetGoal'); + const out = strip(joinRender(renderer(call('GetGoal'), result(goalOutput()), ctx))); + expect(out).toContain('Goal active: Ship feature X'); + expect(out).toContain('2 turns'); + expect(out).toContain('1.2k tokens'); + expect(out).toContain('1m 01s'); + expect(out).not.toContain('"objective"'); + expect(out).not.toContain('"budget"'); + }); + + it('GetGoal renders an empty goal without dumping JSON', () => { + const renderer = pickResultRenderer('GetGoal'); + const out = strip(joinRender(renderer(call('GetGoal'), result('{"goal":null}'), ctx))); + expect(out).toContain('No current goal.'); + expect(out).not.toContain('"goal"'); + }); + + it('CreateGoal renders the created goal summary without raw JSON', () => { + const renderer = pickResultRenderer('CreateGoal'); + const out = strip(joinRender(renderer( + call('CreateGoal', { objective: 'Ship feature X' }), + result(goalOutput()), + ctx, + ))); + expect(out).toContain('Goal active: Ship feature X'); + expect(out).not.toContain('"goalId"'); + }); + + it('UpdateGoal success renders no redundant body', () => { + const renderer = pickResultRenderer('UpdateGoal'); + const out = joinRender( + renderer(call('UpdateGoal', { status: 'complete' }), result('Goal marked complete.'), ctx), + ); + expect(out.trim()).toBe(''); + }); + it('Errors always fall back to truncated renderer regardless of tool', () => { const renderer = pickResultRenderer('Read'); const out = strip( diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 85bb108e3..313da543a 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -68,6 +68,10 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { flushNow: vi.fn(), resetToolUi: vi.fn(), finalizeTurn: vi.fn(), + hasThinkingDraft: vi.fn(() => false), + flushThinkingToTranscript: vi.fn(), + appendAssistantDelta: vi.fn(), + scheduleFlush: vi.fn(), }, requireSession: vi.fn(() => session), setAppState: vi.fn(), @@ -135,6 +139,21 @@ function turnEndedEvent() { } as const; } +function modelBlockedEvent() { + return { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: fakeGoalSnapshot('Blocked goal', 'blocked'), + change: { kind: 'lifecycle', status: 'blocked' }, + } as const; +} + +function addedTranscriptText(host: ReturnType['host']): string { + const component = host.state.transcriptContainer.addChild.mock.calls.at(-1)?.[0]; + return component.render(80).join('\n').replaceAll(/\[[0-9;]*m/g, ''); +} + describe('SessionEventHandler goal queue promotion', () => { beforeEach(() => { vi.mocked(readGoalQueue).mockClear(); @@ -350,6 +369,65 @@ describe('SessionEventHandler goal queue promotion', () => { expect(session.createGoal).not.toHaveBeenCalled(); }); + it('does not render a duplicate marker for a model-reported blocked goal', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(modelBlockedEvent(), vi.fn()); + + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); + }); + + it('renders a blocked fallback when the model does not explain the blocked goal', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(modelBlockedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(addedTranscriptText(host)).toBe(' ◦ Goal blocked'); + }); + + it('does not render a blocked fallback after the model explains the blocked goal', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(modelBlockedEvent(), vi.fn()); + handler.handleEvent( + { + type: 'assistant.delta', + sessionId: 's1', + agentId: 'main', + turnId: 1, + delta: 'I am blocked because I need credentials.', + }, + vi.fn(), + ); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); + }); + + it('does not render a blocked fallback after earlier assistant text in the same turn', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent( + { + type: 'assistant.delta', + sessionId: 's1', + agentId: 'main', + turnId: 1, + delta: 'I am blocked because I need credentials.', + }, + vi.fn(), + ); + handler.handleEvent(modelBlockedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); + }); + it('does not promote on paused or cancelled updates', async () => { const { host, session } = makeHost(); const handler = new SessionEventHandler(host); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 362c9cc37..306e80531 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -406,6 +406,144 @@ describe('KimiTUI resume message replay', () => { expect(transcript).not.toContain('Paused after agent resume'); }); + it('renders replayed goal completion records as assistant completion messages', async () => { + const driver = await replayIntoDriver([ + goalReplay( + goalSnapshot({ + status: 'complete', + turnsUsed: 1, + tokensUsed: 4_300_000, + wallClockMs: 435_000, + }), + { + kind: 'completion', + status: 'complete', + stats: { turnsUsed: 1, tokensUsed: 4_300_000, wallClockMs: 435_000 }, + }, + ), + ]); + + const entry = driver.state.transcriptEntries.find((item) => + item.content.includes('Goal complete'), + ); + expect(entry).toMatchObject({ + kind: 'assistant', + renderMode: 'markdown', + content: '✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.3M tokens.', + }); + }); + + it('does not replay model-facing goal completion prompts as transcript messages', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: '\nGoal completed successfully.\nWorked 1 turn over 7m15s, using 4.3M tokens.\n\nWrite a concise final message for the user.\n', + }, + ], + { origin: { kind: 'system_trigger', name: 'goal_completion' } }, + ), + ]); + + const content = driver.state.transcriptEntries.map((item) => item.content).join('\n'); + expect(content).not.toContain('Goal completed successfully'); + expect(content).not.toContain('Write a concise final message for the user'); + }); + + it('does not replay model-facing goal blocked prompts as transcript messages', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: '\nGoal blocked.\nWorked 1 turn over 7m15s, using 4.3M tokens.\n\nWrite a concise final message for the user.\n', + }, + ], + { origin: { kind: 'system_trigger', name: 'goal_blocked' } }, + ), + ]); + + const content = driver.state.transcriptEntries.map((item) => item.content).join('\n'); + expect(content).not.toContain('Goal blocked.'); + expect(content).not.toContain('Write a concise final message for the user'); + }); + + it('does not replay the model-blocked lifecycle marker when the follow-up is replayed', async () => { + const driver = await replayIntoDriver([ + goalReplay( + goalSnapshot({ status: 'blocked' }), + { kind: 'lifecycle', status: 'blocked', actor: 'model' }, + ), + message( + 'user', + [ + { + type: 'text', + text: '\nGoal blocked.\nWorked 1 turn over 7m15s, using 4.3M tokens.\n\nWrite a concise final message for the user.\n', + }, + ], + { origin: { kind: 'system_trigger', name: 'goal_blocked' } }, + ), + message( + 'assistant', + [{ type: 'text', text: 'I am blocked because I need credentials.' }], + ), + ]); + + expect(driver.state.transcriptEntries.filter((entry) => entry.kind === 'goal')).toEqual([]); + const content = driver.state.transcriptEntries.map((item) => item.content).join('\n'); + expect(content).not.toContain('Goal blocked'); + expect(content).toContain('I am blocked because I need credentials.'); + }); + + it('does not replay model-blocked lifecycle markers without a follow-up', async () => { + const driver = await replayIntoDriver([ + goalReplay( + goalSnapshot({ status: 'blocked' }), + { kind: 'lifecycle', status: 'blocked', actor: 'model' }, + ), + ]); + + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'goal') + .map((entry) => entry.content), + ).toEqual([]); + }); + + it('keeps replayed blocked lifecycle markers when actor is unavailable', async () => { + const driver = await replayIntoDriver([ + goalReplay( + goalSnapshot({ status: 'blocked' }), + { kind: 'lifecycle', status: 'blocked' }, + ), + ]); + + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'goal') + .map((entry) => entry.content), + ).toEqual(['Goal blocked']); + }); + + it('keeps replayed runtime-blocked lifecycle markers', async () => { + const driver = await replayIntoDriver([ + goalReplay( + goalSnapshot({ status: 'blocked' }), + { kind: 'lifecycle', status: 'blocked', actor: 'runtime' }, + ), + ]); + + expect( + driver.state.transcriptEntries + .filter((entry) => entry.kind === 'goal') + .map((entry) => entry.content), + ).toEqual(['Goal blocked']); + }); + it('groups replayed Agent calls from one assistant message using live grouping', async () => { const replay: AgentReplayRecord[] = [ message('user', [{ type: 'text', text: 'run two agents' }]), diff --git a/docs/en/guides/goals.md b/docs/en/guides/goals.md index 82242df6f..d8eb12489 100644 --- a/docs/en/guides/goals.md +++ b/docs/en/guides/goals.md @@ -98,9 +98,9 @@ Use the same command surface to inspect or control the current goal: A goal can stop in three ways: -- **complete**: the objective is done, and Kimi Code clears the goal -- **paused**: you paused it, interrupted the turn, or resumed a session that had an active goal -- **blocked**: Kimi Code needs input, cannot complete the goal as stated, reached a budget limit, or hit a runtime failure +- **complete**: the objective is done, Kimi Code clears the goal, and the agent summarizes how it completed the work +- **paused**: you paused it, interrupted the turn, resumed a session that had an active goal, or hit a model, provider, or runtime error +- **blocked**: Kimi Code needs input, cannot complete the goal as stated, or reached a budget limit. When the agent blocks a goal, it writes a short message explaining why. Write stop conditions into the objective. `/goal` does not have a separate stop-limit flag. diff --git a/docs/zh/guides/goals.md b/docs/zh/guides/goals.md index 1e5dc04ae..13d18cbd9 100644 --- a/docs/zh/guides/goals.md +++ b/docs/zh/guides/goals.md @@ -98,9 +98,9 @@ Kimi Code 会保存该目标,把它作为下一条用户消息发送,并进 目标有三种停止方式: -- **完成(`complete`)**:目标已完成,Kimi Code 会清除该目标 -- **暂停(`paused`)**:你暂停了它、中断了当前轮次,或恢复了原本有目标的会话 -- **阻塞(`blocked`)**:Kimi Code 需要输入、无法按当前表述完成目标、达到预算上限,或遇到运行时失败 +- **完成(`complete`)**:目标已完成,Kimi Code 会清除该目标,Agent 会总结它如何完成了这项工作 +- **暂停(`paused`)**:你暂停了它、中断了当前轮次、恢复了原本有目标的会话,或遇到模型、供应商或运行时错误 +- **阻塞(`blocked`)**:Kimi Code 需要输入、无法按当前表述完成目标,或达到预算上限。当 Agent 将目标标记为阻塞时,它会写一条简短消息说明原因。 停止条件需要写在目标本身里。`/goal` 没有单独用于描述停止限制的语法。 diff --git a/packages/agent-core/src/agent/goal/index.ts b/packages/agent-core/src/agent/goal/index.ts index f534f9789..d8f8bafd3 100644 --- a/packages/agent-core/src/agent/goal/index.ts +++ b/packages/agent-core/src/agent/goal/index.ts @@ -52,9 +52,9 @@ const GOAL_FORK_CLEARED_REMINDER = [ * and resumable via `/goal resume`" — differing only in *who* stopped it (the * user vs the system) and the human-readable `reason`. There is no separate * `impossible`, `budget_limited`, `error`, or `cancelled` status: an - * unachievable goal, an exhausted budget, or a non-retryable runtime failure - * becomes `blocked(+reason)`, retryable runtime stops become `paused(+reason)`, - * and `cancelGoal` discards the record entirely. See {@link GoalMode} + * unachievable goal or an exhausted budget becomes `blocked(+reason)`, + * runtime/model/provider failures become `paused(+reason)`, and `cancelGoal` + * discards the record entirely. See {@link GoalMode} * for the setters and the per-status notes below. */ export type GoalStatus = @@ -70,18 +70,17 @@ export type GoalStatus = * `/goal resume`. Reached three ways: the user pauses (`pauseGoal`); a live * turn is aborted mid-flight, e.g. Esc/shutdown (`pauseOnInterrupt`); or a * agent is resumed from disk, where an `active` goal cannot still be running - * and is demoted (`normalizeAfterReplay`); or a retryable runtime stop such as a - * provider rate limit parked it via `pauseActiveGoal`. + * and is demoted (`normalizeAfterReplay`); or a runtime/model/provider failure + * parked it via `pauseActiveGoal`. */ | 'paused' /** * The *system* stopped pursuing the goal, for a reason carried in * `terminalReason`: the model reported it cannot proceed via * `UpdateGoal('blocked')` (an external blocker, or an objective it deems - * unachievable); a configured hard budget (token/turn/time) was reached; or a - * non-retryable runtime failure occurred. Set by `markBlocked` (from the - * model's `UpdateGoal`, the budget check in the goal driver, and the driver's - * turn-failure catch). + * unachievable); or a configured hard budget (token/turn/time) was reached. + * Set by `markBlocked` from the model's `UpdateGoal`, the budget check in the + * goal driver, and prompt-hook blocks. * Resumable like `paused` — `/goal resume` re-activates it; a plain message * just runs one normal turn without reactivating the loop. Editing the goal * while blocked takes effect on the next turn. @@ -96,7 +95,7 @@ export type GoalStatus = | 'complete'; /** Who performed a goal action. `cleared` is a record action, not a status. */ -type GoalActor = 'user' | 'model' | 'runtime' | 'system'; +export type GoalActor = 'user' | 'model' | 'runtime' | 'system'; export interface GoalBudgetLimits { readonly tokenBudget?: number; @@ -184,6 +183,7 @@ export interface GoalChange { readonly status?: GoalStatus; readonly reason?: string; readonly stats?: GoalChangeStats; + readonly actor?: GoalActor; } export interface CreateGoalInput { @@ -202,17 +202,18 @@ interface GoalReasonInput { * Lifecycle rules (see the {@link GoalStatus} union for the full per-status map): * - Success: `markComplete` records success then clears the record (transient). * The model marks completion via the `UpdateGoal('complete')` tool; the turn - * driver reads the status at the turn boundary. `markComplete` emits a final - * snapshot event, then clears the record. - * - System stop: `markBlocked(reason)` sets `blocked` for any reason the system - * stops pursuing — the model's `UpdateGoal('blocked')`, a hard budget, or a - * runtime error. `blocked` is resumable. - * - User stop: `pauseGoal` and the interrupt path `pauseOnInterrupt` set `paused` - * (resumable); `cancelGoal` discards the record entirely (no status — this is - * what `/goal cancel` does, the single remove action). - * - An aborted turn (Esc / shutdown) is not terminal: it pauses the goal, so it - * stays resumable — mirroring how `normalizeAfterReplay` demotes an `active` - * goal to `paused` on agent resume. + * driver reads the status at the turn boundary. `markComplete` announces, then + * clears the record. + * - Task stop: `markBlocked(reason)` sets `blocked` when the model cannot + * proceed, a prompt hook blocks, or a hard budget is reached. `blocked` is + * resumable. + * - Pause: `pauseGoal`, `pauseActiveGoal`, and the interrupt path + * `pauseOnInterrupt` set `paused` (resumable); `cancelGoal` discards the + * record entirely (no status — this is what `/goal cancel` does, the single + * remove action). + * - An aborted or failed turn is not terminal: it pauses the goal, so it stays + * resumable — mirroring how `normalizeAfterReplay` demotes an `active` goal to + * `paused` on agent resume. */ export class GoalMode { private state: GoalState | undefined; @@ -299,11 +300,13 @@ export class GoalMode { status, reason: record.reason, stats: this.statsOf(state), + actor: record.actor, } : { kind: 'lifecycle', status, reason: record.reason, + actor: record.actor, }, }); } @@ -404,7 +407,7 @@ export class GoalMode { this.applyStatus(state, 'paused'); state.terminalReason = input.reason; this.persistState(state, { - change: { kind: 'lifecycle', status: 'paused', reason: input.reason }, + change: { kind: 'lifecycle', status: 'paused', reason: input.reason, actor }, }); this.appendStatusUpdate(state, actor, input.reason); return this.toSnapshot(state); @@ -424,7 +427,7 @@ export class GoalMode { this.applyStatus(state, 'paused'); state.terminalReason = input.reason; this.persistState(state, { - change: { kind: 'lifecycle', status: 'paused', reason: input.reason }, + change: { kind: 'lifecycle', status: 'paused', reason: input.reason, actor }, }); this.appendStatusUpdate(state, actor, input.reason); return this.toSnapshot(state); @@ -444,7 +447,7 @@ export class GoalMode { state.terminalReason = undefined; this.applyStatus(state, 'active'); this.persistState(state, { - change: { kind: 'lifecycle', status: 'active', reason: input.reason }, + change: { kind: 'lifecycle', status: 'active', reason: input.reason, actor }, }); this.appendStatusUpdate(state, actor, input.reason); return this.toSnapshot(state); @@ -491,7 +494,7 @@ export class GoalMode { /** * Marks the goal `blocked`: the system stopped pursuing it for `reason` — the * model's `UpdateGoal('blocked')` (incl. objectives it deems unachievable), a - * hard budget reached by the goal driver, or a runtime failure in the driver. + * hard budget reached by the goal driver, or a prompt-hook block. * `blocked` is persisted and **resumable** via * `/goal resume` (it is a sibling of `paused`, not a dead end), so it emits a * `lifecycle` change. No-ops for a goal that is missing or not active, so a @@ -506,7 +509,7 @@ export class GoalMode { this.applyStatus(state, 'blocked'); state.terminalReason = input.reason; this.persistState(state, { - change: { kind: 'lifecycle', status: 'blocked', reason: input.reason }, + change: { kind: 'lifecycle', status: 'blocked', reason: input.reason, actor }, }); this.appendStatusUpdate(state, actor, input.reason); return this.toSnapshot(state); @@ -535,6 +538,7 @@ export class GoalMode { status: 'complete', reason: input.reason, stats: this.statsOf(state), + actor, }); // ...then clear the durable record (emits onGoalUpdated(null) → box clears). this.clearInternal(actor); @@ -599,6 +603,7 @@ export class GoalMode { status: state.status, reason, wallClockMs: liveWallClockMs(state, Date.now()), + actor, }); this.track('goal_status_changed', { actor, diff --git a/packages/agent-core/src/agent/records/migration/v1.4.ts b/packages/agent-core/src/agent/records/migration/v1.4.ts index bc5cd73d0..45d556c68 100644 --- a/packages/agent-core/src/agent/records/migration/v1.4.ts +++ b/packages/agent-core/src/agent/records/migration/v1.4.ts @@ -1,6 +1,7 @@ import type { WireMigration, WireMigrationRecord } from './index'; type V1_3GoalStatus = 'active' | 'paused' | 'blocked' | 'complete'; +type V1_3GoalActor = 'user' | 'model' | 'runtime' | 'system'; interface TimedWireMigrationRecord extends WireMigrationRecord { readonly time?: number; @@ -21,6 +22,7 @@ interface V1_3GoalUpdateRecord extends TimedWireMigrationRecord { readonly turnsUsed?: number; readonly tokensUsed?: number; readonly wallClockMs?: number; + readonly actor?: V1_3GoalActor; } interface V1_3GoalAccountUsageRecord extends TimedWireMigrationRecord { @@ -80,6 +82,7 @@ function migrateGoalUpdate(record: V1_3GoalUpdateRecord): WireMigrationRecord { turnsUsed: record.turnsUsed, tokensUsed: record.tokensUsed, wallClockMs: record.wallClockMs, + actor: record.actor, time: record.time, }; } diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index d6b58d891..835c34465 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -1,7 +1,7 @@ import type { ContentPart, TokenUsage } from '@moonshot-ai/kosong'; import type { LoopRecordedEvent } from '../../loop'; -import type { GoalBudgetLimits, GoalStatus } from '../goal'; +import type { GoalActor, GoalBudgetLimits, GoalStatus } from '../goal'; import type { ToolStoreUpdate } from '../../tools/store'; import type { CompactionBeginData, CompactionResult } from '../compaction'; import type { AgentConfigUpdateData } from '../config'; @@ -97,6 +97,7 @@ export interface AgentRecordEvents { wallClockMs?: number; budgetLimits?: GoalBudgetLimits; reason?: string; + actor?: GoalActor; }; 'goal.clear': {}; } diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 7a869af57..e583a83d8 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -68,7 +68,14 @@ const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login'; /** Origin tag for the synthetic "continue" prompt that drives each goal turn. */ const GOAL_CONTINUATION_ORIGIN: PromptOrigin = { kind: 'system_trigger', name: 'goal_continuation' }; +export const GOAL_COMPLETION_REMINDER_NAME = 'goal_completion'; +export const GOAL_BLOCKED_REMINDER_NAME = 'goal_blocked'; const GOAL_RATE_LIMIT_PAUSE_REASON = 'Paused after provider rate limit'; +const GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX = 'Paused after provider connection error'; +const GOAL_PROVIDER_AUTH_PAUSE_PREFIX = 'Paused after provider authentication error'; +const GOAL_PROVIDER_API_PAUSE_PREFIX = 'Paused after provider API error'; +const GOAL_MODEL_CONFIG_PAUSE_PREFIX = 'Paused after model configuration error'; +const GOAL_RUNTIME_PAUSE_PREFIX = 'Paused after runtime error'; /** * The prompt the goal driver appends to start each continuation turn — the @@ -324,9 +331,9 @@ export class TurnFlow { * full turn, then reads the goal status the model set via `UpdateGoal`: * `complete` (the record is cleared) / `blocked` / `paused` stop the loop; * `active` (the model didn't decide) re-injects the goal reminder and runs the - * next continuation turn. An aborted turn pauses the goal; a provider rate - * limit also pauses it. Other failed turns block it (all resumable). Returns - * the final turn's result. + * next continuation turn. Aborted or failed turns pause the goal. Goal-state + * blockers, such as explicit `UpdateGoal('blocked')`, prompt-hook blocks, and + * budget limits, block it (all resumable). Returns the final turn's result. */ private async driveGoal( firstTurnId: number, @@ -357,14 +364,7 @@ export class TurnFlow { return end; } if (end.event.reason === 'failed') { - const pauseReason = goalFailurePauseReason(end.event.error); - if (pauseReason !== null) { - await this.agent.goal.pauseActiveGoal({ reason: pauseReason }); - return end; - } - await this.agent.goal.markBlocked({ - reason: `Runtime error: ${end.event.error?.message ?? 'unknown'}`, - }); + await this.agent.goal.pauseActiveGoal({ reason: goalFailurePauseReason(end.event.error) }); return end; } if (end.blockedByUserPromptHook === true) { @@ -562,6 +562,7 @@ export class TurnFlow { private async runStepLoop(turnId: number, signal: AbortSignal): Promise { let stopHookContinuationUsed = false; + let goalOutcomeMessageContinuationUsed = false; const deduper = new ToolCallDeduplicator({ telemetry: this.agent.telemetry }); await this.agent.mcp?.waitForInitialLoad(signal); // Surface the active goal at the start of the turn (append-only; no-op when @@ -614,7 +615,21 @@ export class TurnFlow { if (this.flushSteerBuffer()) return { continue: true }; signal.throwIfAborted(); - // 2. The external Stop hook gets exactly one continuation; the cap + // 2. After UpdateGoal marks a goal terminal, ask the model for one + // final user-facing outcome message before the turn ends. + if ( + !goalOutcomeMessageContinuationUsed && + isGoalOutcomeReminderOrigin(this.agent.context.history.at(-1)?.origin) + ) { + goalOutcomeMessageContinuationUsed = true; + if (!hasStepBudgetRemaining(loopControl?.maxStepsPerTurn, ctx.stepNumber)) { + this.agent.context.popMatchedMessage(isGoalOutcomeReminderOrigin); + return { continue: false }; + } + return { continue: true }; + } + + // 3. The external Stop hook gets exactly one continuation; the cap // is intentionally separate from (and does not cap) goal mode. if (!stopHookContinuationUsed) { const stopBlock = await this.agent.hooks?.triggerBlock('Stop', { @@ -635,7 +650,7 @@ export class TurnFlow { } } - // 3. Otherwise stop. Goal continuation is no longer driven here: + // 4. Otherwise stop. Goal continuation is no longer driven here: // each goal turn is an ordinary turn, and the goal driver decides // whether to run another after this one ends. return { continue: false }; @@ -848,6 +863,18 @@ export class TurnFlow { } } +function isGoalOutcomeReminderOrigin(origin: PromptOrigin | undefined): boolean { + return ( + origin?.kind === 'system_trigger' && + (origin.name === GOAL_COMPLETION_REMINDER_NAME || + origin.name === GOAL_BLOCKED_REMINDER_NAME) + ); +} + +function hasStepBudgetRemaining(maxSteps: number | undefined, currentStep: number): boolean { + return maxSteps === undefined || maxSteps <= 0 || currentStep < maxSteps; +} + function mapLoopEvent(event: LoopEvent, turnId: number): AgentEvent | undefined { switch (event.type) { case 'step.begin': @@ -957,9 +984,28 @@ function summarizeTurnError(error: unknown, turnId: number): KimiErrorPayload { return { ...payload, details }; } -function goalFailurePauseReason(error: KimiErrorPayload | undefined): string | null { +function goalFailurePauseReason(error: KimiErrorPayload | undefined): string { if (error?.code === ErrorCodes.PROVIDER_RATE_LIMIT) return GOAL_RATE_LIMIT_PAUSE_REASON; - return null; + if (error?.code === ErrorCodes.PROVIDER_CONNECTION_ERROR) { + return pauseReasonWithMessage(GOAL_PROVIDER_CONNECTION_PAUSE_PREFIX, error.message); + } + if (error?.code === ErrorCodes.PROVIDER_AUTH_ERROR) { + return pauseReasonWithMessage(GOAL_PROVIDER_AUTH_PAUSE_PREFIX, error.message); + } + if (error?.code === ErrorCodes.PROVIDER_API_ERROR) { + return pauseReasonWithMessage(GOAL_PROVIDER_API_PAUSE_PREFIX, error.message); + } + if ( + error?.code === ErrorCodes.MODEL_NOT_CONFIGURED || + error?.code === ErrorCodes.MODEL_CONFIG_INVALID + ) { + return pauseReasonWithMessage(GOAL_MODEL_CONFIG_PAUSE_PREFIX, error.message); + } + return pauseReasonWithMessage(GOAL_RUNTIME_PAUSE_PREFIX, error?.message); +} + +function pauseReasonWithMessage(prefix: string, message: string | undefined): string { + return message === undefined || message.length === 0 ? prefix : `${prefix}: ${message}`; } function toolInputRecord(args: unknown): Record { diff --git a/packages/agent-core/src/tools/builtin/goal/outcome-prompts.ts b/packages/agent-core/src/tools/builtin/goal/outcome-prompts.ts new file mode 100644 index 000000000..d3279b0e9 --- /dev/null +++ b/packages/agent-core/src/tools/builtin/goal/outcome-prompts.ts @@ -0,0 +1,46 @@ +import type { GoalSnapshot } from '../../../agent/goal'; + +export function buildGoalCompletionSummaryPrompt(goal: GoalSnapshot): string { + return [ + buildGoalCompletionPromptMessage(goal), + '', + 'Write a concise final message for the user. State that the goal is complete, summarize the main work completed, and mention any validation you ran. Do not call more goal tools.', + ].join('\n'); +} + +export function buildGoalBlockedReasonPrompt(goal: GoalSnapshot): string { + return [ + buildGoalBlockedMessage(goal), + '', + 'Write a concise final message for the user. State that the goal is blocked, explain the concrete blocker, and say what input or change is needed before work can continue. Do not call more goal tools.', + ].join('\n'); +} + +function buildGoalCompletionPromptMessage(goal: GoalSnapshot): string { + const head = `Goal completed successfully${goal.terminalReason ? `: ${goal.terminalReason}` : ''}.`; + const turns = `${goal.turnsUsed} turn${goal.turnsUsed === 1 ? '' : 's'}`; + const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokens(goal.tokensUsed)} tokens.`; + return `${head}\n${stats}`; +} + +function buildGoalBlockedMessage(goal: GoalSnapshot): string { + const turns = `${goal.turnsUsed} turn${goal.turnsUsed === 1 ? '' : 's'}`; + const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokens(goal.tokensUsed)} tokens.`; + return `Goal blocked.\n${stats}`; +} + +function formatElapsed(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${String(totalSeconds)}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return `${String(minutes)}m${seconds.toString().padStart(2, '0')}s`; + const hours = Math.floor(minutes / 60); + return `${String(hours)}h${(minutes % 60).toString().padStart(2, '0')}m`; +} + +function formatTokens(tokens: number): string { + if (tokens < 1000) return String(tokens); + if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(1)}k`; + return `${(tokens / 1_000_000).toFixed(1)}M`; +} diff --git a/packages/agent-core/src/tools/builtin/goal/update-goal.md b/packages/agent-core/src/tools/builtin/goal/update-goal.md index a31751c1f..e27ef8c4a 100644 --- a/packages/agent-core/src/tools/builtin/goal/update-goal.md +++ b/packages/agent-core/src/tools/builtin/goal/update-goal.md @@ -5,4 +5,4 @@ Set the status of the current goal. This is how you resume, end, or yield an aut - `blocked` — an external condition or required user input prevents progress, or the objective cannot be completed as stated. The goal stops but can be resumed later. - `paused` — set the goal aside for now (e.g. to hand control back to the user). It can be resumed later. -If the goal is active and you do not call this, the goal keeps running: after your turn ends you will be prompted to continue. Call `complete` only when all required work is done, any stated validation has passed, and there is no useful next action. Do not call `complete` after only producing a plan, summary, first pass, or partial result. Explain your reasoning in your reply; this tool only records the status. +If the goal is active and you do not call this, the goal keeps running: after your turn ends you will be prompted to continue. Call `complete` only when all required work is done, any stated validation has passed, and there is no useful next action. Do not call `complete` after only producing a plan, summary, first pass, or partial result. If you call `blocked`, you will be prompted to explain the blocker in your next message. This tool only records the status. diff --git a/packages/agent-core/src/tools/builtin/goal/update-goal.ts b/packages/agent-core/src/tools/builtin/goal/update-goal.ts index 0d972465a..52110a3c0 100644 --- a/packages/agent-core/src/tools/builtin/goal/update-goal.ts +++ b/packages/agent-core/src/tools/builtin/goal/update-goal.ts @@ -13,16 +13,19 @@ import type { Agent } from '#/agent'; import { z } from 'zod'; +import { + GOAL_BLOCKED_REMINDER_NAME, + GOAL_COMPLETION_REMINDER_NAME, +} from '../../../agent/turn'; +import { + buildGoalBlockedReasonPrompt, + buildGoalCompletionSummaryPrompt, +} from './outcome-prompts'; import type { BuiltinTool } from '../../../agent/tool'; import type { ToolExecution } from '../../../loop/types'; import { toInputJsonSchema } from '../../support/input-schema'; import DESCRIPTION from './update-goal.md'; -const GOAL_COMPLETED_CONTEXT_REMINDER = [ - 'The current goal was marked complete and cleared.', - 'Handle the next user request normally unless the user starts or resumes a goal.', -].join(' '); - export const UpdateGoalToolInputSchema = z .object({ status: z @@ -54,21 +57,27 @@ export class UpdateGoalTool implements BuiltinTool { } if (args.status === 'complete') { const completed = await goal.markComplete({}, 'model'); - // `complete` is transient — markComplete announces then clears the - // record. Add a neutral context reminder so the next provider - // request ends with a user message after the UpdateGoal tool result. - // Anthropic-compatible providers reject trailing assistant messages - // as unsupported prefill. + // `complete` is transient: markComplete announces then clears the + // record. Store the summary request as a system reminder, so the next + // provider request ends with a user message after the UpdateGoal tool + // result. Anthropic-compatible providers reject trailing assistant + // messages as unsupported prefill. if (completed !== null) { - this.agent.context.appendSystemReminder(GOAL_COMPLETED_CONTEXT_REMINDER, { + this.agent.context.appendSystemReminder(buildGoalCompletionSummaryPrompt(completed), { kind: 'system_trigger', - name: 'goal_completion', + name: GOAL_COMPLETION_REMINDER_NAME, }); } return { output: 'Goal marked complete.', stopTurn: true }; } if (args.status === 'blocked') { - await goal.markBlocked({}, 'model'); + const blocked = await goal.markBlocked({}, 'model'); + if (blocked !== null) { + this.agent.context.appendSystemReminder(buildGoalBlockedReasonPrompt(blocked), { + kind: 'system_trigger', + name: GOAL_BLOCKED_REMINDER_NAME, + }); + } return { output: 'Goal marked blocked.', stopTurn: true }; } await goal.pauseGoal({}, 'model'); diff --git a/packages/agent-core/test/agent/goal-outcome.test.ts b/packages/agent-core/test/agent/goal-outcome.test.ts new file mode 100644 index 000000000..01835c915 --- /dev/null +++ b/packages/agent-core/test/agent/goal-outcome.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildGoalBlockedReasonPrompt, + buildGoalCompletionSummaryPrompt, +} from '../../src/tools/builtin/goal/outcome-prompts'; +import type { GoalSnapshot } from '../../src/agent/goal'; + +function snapshot(overrides: Partial = {}): GoalSnapshot { + return { + objective: 'work', + status: 'complete', + turnsUsed: 3, + tokensUsed: 12_500, + wallClockMs: 260_000, + terminalReason: 'all tests pass', + ...overrides, + } as GoalSnapshot; +} + +describe('goal outcome prompts', () => { + it('uses stronger ASCII-only wording in the completion prompt sent to the model', () => { + const text = buildGoalCompletionSummaryPrompt(snapshot()); + expect(text).toContain('Goal completed successfully: all tests pass.'); + expect(text).toContain('Write a concise final message for the user'); + expect(text).not.toContain('✓'); + expect(text).not.toContain('—'); + }); + + it('uses stronger wording in the blocked prompt sent to the model', () => { + const text = buildGoalBlockedReasonPrompt(snapshot({ status: 'blocked' })); + expect(text).toContain('Goal blocked.'); + expect(text).toContain('State that the goal is blocked'); + expect(text).toContain('concrete blocker'); + }); +}); diff --git a/packages/agent-core/test/agent/goal.test.ts b/packages/agent-core/test/agent/goal.test.ts index 68318d2c0..408c5de7c 100644 --- a/packages/agent-core/test/agent/goal.test.ts +++ b/packages/agent-core/test/agent/goal.test.ts @@ -279,6 +279,7 @@ describe('GoalMode records', () => { type: 'goal.update', status: 'blocked', reason: 'stuck', + actor: 'runtime', }), expect.objectContaining({ type: 'goal.clear' }), ]); @@ -328,9 +329,19 @@ describe('GoalMode records', () => { }); goals.restoreUpdate({ type: 'goal.update', tokensUsed: 5 }); goals.restoreUpdate({ type: 'goal.update', turnsUsed: 1 }); - goals.restoreUpdate({ type: 'goal.update', status: 'paused', reason: 'break' }); - goals.restoreUpdate({ type: 'goal.update', status: 'active' }); - goals.restoreUpdate({ type: 'goal.update', status: 'complete', reason: 'done' }); + goals.restoreUpdate({ + type: 'goal.update', + status: 'paused', + reason: 'break', + actor: 'runtime', + }); + goals.restoreUpdate({ type: 'goal.update', status: 'active', actor: 'user' }); + goals.restoreUpdate({ + type: 'goal.update', + status: 'complete', + reason: 'done', + actor: 'model', + }); expect(replay).toEqual([ expect.objectContaining({ @@ -341,12 +352,12 @@ describe('GoalMode records', () => { expect.objectContaining({ type: 'goal_updated', snapshot: expect.objectContaining({ status: 'paused', terminalReason: 'break' }), - change: { kind: 'lifecycle', status: 'paused', reason: 'break' }, + change: { kind: 'lifecycle', status: 'paused', reason: 'break', actor: 'runtime' }, }), expect.objectContaining({ type: 'goal_updated', snapshot: expect.objectContaining({ status: 'active' }), - change: { kind: 'lifecycle', status: 'active', reason: undefined }, + change: { kind: 'lifecycle', status: 'active', reason: undefined, actor: 'user' }, }), expect.objectContaining({ type: 'goal_updated', @@ -361,6 +372,7 @@ describe('GoalMode records', () => { status: 'complete', reason: 'done', stats: { turnsUsed: 1, tokensUsed: 5, wallClockMs: 0 }, + actor: 'model', }, }), ]); @@ -388,6 +400,7 @@ describe('GoalMode records', () => { kind: 'lifecycle', status: 'paused', reason: 'Paused after agent resume', + actor: undefined, }, }); }); diff --git a/packages/agent-core/test/agent/records/index.test.ts b/packages/agent-core/test/agent/records/index.test.ts index d4291790f..12dbf4ec1 100644 --- a/packages/agent-core/test/agent/records/index.test.ts +++ b/packages/agent-core/test/agent/records/index.test.ts @@ -197,7 +197,7 @@ describe('AgentRecords persistence metadata', () => { { type: 'goal.update', budgetLimits: { turnBudget: 20 } }, { type: 'goal.update', tokensUsed: 5, wallClockMs: 0 }, { type: 'goal.update', turnsUsed: 1 }, - { type: 'goal.update', status: 'blocked', reason: 'needs credentials' }, + { type: 'goal.update', status: 'blocked', reason: 'needs credentials', actor: 'model' }, ]); const { agent } = testAgent({ persistence }); @@ -230,6 +230,7 @@ describe('AgentRecords persistence metadata', () => { kind: 'lifecycle', status: 'blocked', reason: 'needs credentials', + actor: 'model', }, }), ]); diff --git a/packages/agent-core/test/agent/records/migration/v1.4.test.ts b/packages/agent-core/test/agent/records/migration/v1.4.test.ts index 2abcbfd0e..e5e4ae0b6 100644 --- a/packages/agent-core/test/agent/records/migration/v1.4.test.ts +++ b/packages/agent-core/test/agent/records/migration/v1.4.test.ts @@ -68,7 +68,7 @@ describe('1.3 to 1.4', () => { [wire] goal.create { "goalId": "goal-1", "objective": "ship the feature", "completionCriterion": "tests pass", "time": "