From 4df6a3d916b94bda789c2520f0a5878efa61e072 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:31:36 +0800 Subject: [PATCH 01/25] fix: specialize goal tool display --- .changeset/goal-tool-display.md | 5 + .../src/tui/components/messages/tool-call.ts | 10 + .../messages/tool-renderers/chip.ts | 10 + .../messages/tool-renderers/goal.ts | 234 ++++++++++++++++++ .../messages/tool-renderers/registry.ts | 6 + .../tui/components/messages/tool-call.test.ts | 91 +++++++ .../messages/tool-renderers/chip.test.ts | 22 ++ .../messages/tool-renderers/registry.test.ts | 67 +++++ 8 files changed, 445 insertions(+) create mode 100644 .changeset/goal-tool-display.md create mode 100644 apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts diff --git a/.changeset/goal-tool-display.md b/.changeset/goal-tool-display.md new file mode 100644 index 000000000..2cf9e48fd --- /dev/null +++ b/.changeset/goal-tool-display.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show goal tool calls in the TUI with concise goal summaries instead of raw JSON payloads. 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..74f714ee0 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 { formatGoalBudgetArg, goalStatusChip } from './goal'; import { readMediaChip } from './media'; import { strArg } from './types'; @@ -110,6 +111,12 @@ const webSearchChip: ChipProvider = (_toolCall, result) => { return pluralize(count, 'result'); }; +const goalStatusOutputChip: ChipProvider = (_toolCall, result) => + result.is_error ? '' : goalStatusChip(result.output); + +const goalBudgetChip: ChipProvider = (toolCall, result) => + result.is_error ? '' : (formatGoalBudgetArg(toolCall.args) ?? ''); + const REGISTRY: Record = { Edit: editChip, Write: writeChip, @@ -119,6 +126,9 @@ const REGISTRY: Record = { Glob: globChip, FetchURL: fetchChip, WebSearch: webSearchChip, + CreateGoal: goalStatusOutputChip, + GetGoal: goalStatusOutputChip, + SetGoalBudget: goalBudgetChip, }; 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..ccfb08173 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -0,0 +1,234 @@ +import { Text } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; +import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; +import { formatTokenCount } from '#/utils/usage/usage-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 arg = + toolCall.name === 'UpdateGoal' + ? undefined + : formatGoalToolArgument(toolCall.name, toolCall.args); + const argText = arg === undefined ? '' : chalk.hex(colors.textDim)(` (${arg})`); + return `${bullet}${label}${argText}${chip}`; +} + +export 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 [ + pluralize(goal.turnsUsed, 'turn'), + `${formatTokenCount(goal.tokensUsed)} tokens`, + formatElapsed(goal.wallClockMs), + ].join(' · '); +} + +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 pluralize(n: number, singular: string, plural?: string): string { + return `${String(n)} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; +} + +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/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index dbac42876..3f4ad52c8 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 @@ -486,6 +486,97 @@ 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('Used SetGoalBudget (turns)'); + expect(out).not.toContain('Goal budget set: 10 turns.'); + }); + + 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('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..2ccd3324f 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,28 @@ 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 chip shows the configured budget', () => { + expect(chipFor('SetGoalBudget', { value: 10, unit: 'turns' }, result('Goal budget set.'))).toBe( + '10 turns', + ); + }); + + 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( From 6a276ac1a08e11257cc110b65ae560a87e90df9c Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:39:36 +0800 Subject: [PATCH 02/25] fix: summarize completed goals --- .changeset/goal-completion-summary.md | 6 ++++ docs/en/guides/goals.md | 2 +- docs/zh/guides/goals.md | 2 +- .../agent-core/src/agent/goal/completion.ts | 18 ++++++++---- packages/agent-core/src/agent/turn/index.ts | 23 +++++++++++++-- .../src/tools/builtin/goal/update-goal.ts | 17 ++++++----- .../test/harness/goal-session.test.ts | 28 ++++++++++++------- 7 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 .changeset/goal-completion-summary.md diff --git a/.changeset/goal-completion-summary.md b/.changeset/goal-completion-summary.md new file mode 100644 index 000000000..24f6a7282 --- /dev/null +++ b/.changeset/goal-completion-summary.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Ask the agent to summarize completed goals after it marks them complete. diff --git a/docs/en/guides/goals.md b/docs/en/guides/goals.md index 49c6e27db..d029f31e4 100644 --- a/docs/en/guides/goals.md +++ b/docs/en/guides/goals.md @@ -106,7 +106,7 @@ 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 +- **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, 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 diff --git a/docs/zh/guides/goals.md b/docs/zh/guides/goals.md index bb69878c3..e6691d13b 100644 --- a/docs/zh/guides/goals.md +++ b/docs/zh/guides/goals.md @@ -106,7 +106,7 @@ Kimi Code 会保存该目标,把它作为下一条用户消息发送,并进 目标有三种停止方式: -- **完成(`complete`)**:目标已完成,Kimi Code 会清除该目标 +- **完成(`complete`)**:目标已完成,Kimi Code 会清除该目标,Agent 会总结它如何完成了这项工作 - **暂停(`paused`)**:你暂停了它、中断了当前轮次,或恢复了原本有目标的会话 - **阻塞(`blocked`)**:Kimi Code 需要输入、无法按当前表述完成目标、达到预算上限,或遇到运行时失败 diff --git a/packages/agent-core/src/agent/goal/completion.ts b/packages/agent-core/src/agent/goal/completion.ts index abd298b50..02c6b0857 100644 --- a/packages/agent-core/src/agent/goal/completion.ts +++ b/packages/agent-core/src/agent/goal/completion.ts @@ -1,12 +1,10 @@ import type { GoalSnapshot } from '../../session/goal'; +export const GOAL_COMPLETION_REMINDER_NAME = 'goal_completion'; + /** - * The deterministic goal-completion message. When the model marks a goal - * `complete` via UpdateGoal, the tool stores this verbatim inside a - * `` (so it persists in the conversation without creating an - * assistant prefill), and the TUI renders the same text live off the completion - * event. It is built from the - * final snapshot — not the model — so the figures (turns / tokens / time) are + * The deterministic goal-completion message. It is built from the final + * snapshot — not the model — so the figures (turns / tokens / time) are * guaranteed exact. */ export function buildGoalCompletionMessage(goal: GoalSnapshot): string { @@ -16,6 +14,14 @@ export function buildGoalCompletionMessage(goal: GoalSnapshot): string { return `${head}\n${stats}`; } +export function buildGoalCompletionSummaryPrompt(goal: GoalSnapshot): string { + return [ + buildGoalCompletionMessage(goal), + '', + 'Now summarize how you completed the goal for the user. Mention the main work completed and any validation you ran. Do not call more goal tools.', + ].join('\n'); +} + function formatElapsed(ms: number): string { const totalSeconds = Math.round(ms / 1000); if (totalSeconds < 60) return `${totalSeconds}s`; diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 8901ca3fd..73d9a0a4c 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -37,6 +37,7 @@ import type { AgentEvent, TurnEndedEvent } from '../../rpc'; import type { TelemetryPropertyValue } from '../../telemetry'; import { abortable, userCancellationReason } from '../../utils/abort'; import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; +import { GOAL_COMPLETION_REMINDER_NAME } from '../goal/completion'; import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks'; import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args'; import { ToolCallDeduplicator } from './tool-dedup'; @@ -568,6 +569,7 @@ export class TurnFlow { private async runStepLoop(turnId: number, signal: AbortSignal): Promise { let stopHookContinuationUsed = false; + let goalCompletionSummaryContinuationUsed = 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 @@ -627,7 +629,17 @@ 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 complete, ask the model for one + // final user-facing summary before the turn ends. + if ( + !goalCompletionSummaryContinuationUsed && + isGoalCompletionReminder(this.agent.context.history.at(-1)) + ) { + goalCompletionSummaryContinuationUsed = true; + 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', { @@ -648,7 +660,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 }; @@ -861,6 +873,13 @@ export class TurnFlow { } } +function isGoalCompletionReminder(message: { readonly origin?: PromptOrigin | undefined } | undefined): boolean { + return ( + message?.origin?.kind === 'system_trigger' && + message.origin.name === GOAL_COMPLETION_REMINDER_NAME + ); +} + function mapLoopEvent(event: LoopEvent, turnId: number): AgentEvent | undefined { switch (event.type) { case 'step.begin': 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 51cd7fb68..a6095607c 100644 --- a/packages/agent-core/src/tools/builtin/goal/update-goal.ts +++ b/packages/agent-core/src/tools/builtin/goal/update-goal.ts @@ -13,7 +13,10 @@ import type { Agent } from '#/agent'; import { z } from 'zod'; -import { buildGoalCompletionMessage } from '../../../agent/goal/completion'; +import { + GOAL_COMPLETION_REMINDER_NAME, + buildGoalCompletionSummaryPrompt, +} from '../../../agent/goal/completion'; import type { BuiltinTool } from '../../../agent/tool'; import type { ToolExecution } from '../../../loop/types'; import { toInputJsonSchema } from '../../support/input-schema'; @@ -54,14 +57,14 @@ export class UpdateGoalTool implements BuiltinTool { if (args.status === 'complete') { const completed = await store.markComplete({ actor: 'model' }); // `complete` is transient — markComplete announces then clears the - // record. Store the deterministic completion line 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. + // 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(buildGoalCompletionMessage(completed), { + this.agent.context.appendSystemReminder(buildGoalCompletionSummaryPrompt(completed), { kind: 'system_trigger', - name: 'goal_completion', + name: GOAL_COMPLETION_REMINDER_NAME, }); } return { output: 'Goal marked complete.', stopTurn: true }; diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index 275178172..b3da3a10d 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -116,8 +116,8 @@ describe('goal session end-to-end', () => { await api.createGoal({ objective: 'Ship feature X', completionCriterion: 'tests pass' }); // Turn 1 stops without deciding -> the driver runs a second turn. In turn 2 - // the model calls UpdateGoal('complete'), which clears the goal and ends the - // drive. No evaluator: the model's own tool call is the decision. + // the model calls UpdateGoal('complete'), which clears the goal. The turn + // then gives the model one final step to summarize how it finished. scripted.mockNextResponse({ type: 'text', text: 'Working on the objective.' }); scripted.mockNextResponse({ type: 'function', @@ -125,6 +125,10 @@ describe('goal session end-to-end', () => { name: 'UpdateGoal', arguments: JSON.stringify({ status: 'complete' }), }); + scripted.mockNextResponse({ + type: 'text', + text: 'I completed the goal by updating the feature and running the tests.', + }); agent.turn.prompt([{ type: 'text', text: 'Ship feature X' }]); // Wait for the whole goal drive (many turns), not just the first turn.ended. @@ -145,14 +149,16 @@ describe('goal session end-to-end', () => { expect(continuationHistory).toContain('Keep the self-audit brief'); expect(continuationHistory).toContain('do not run another goal turn'); - // Terminal UpdateGoal ends the turn immediately. The completion reminder is - // still appended after the tool result, so any later request ends with a - // user message rather than an assistant prefill. - expect(scripted.calls).toHaveLength(2); + // Terminal UpdateGoal asks the model for one final user-facing summary. + expect(scripted.calls).toHaveLength(3); + const summaryHistory = JSON.stringify(scripted.calls[2]?.history ?? []); + expect(summaryHistory).toContain('Goal complete.'); + expect(summaryHistory).toContain('summarize how you completed the goal'); const lastContextMessage = agent.context.history.at(-1); - expect(lastContextMessage?.role).toBe('user'); - expect(JSON.stringify(lastContextMessage?.content)).toContain(''); - expect(JSON.stringify(lastContextMessage?.content)).toContain('Goal complete.'); + expect(lastContextMessage?.role).toBe('assistant'); + expect(JSON.stringify(lastContextMessage?.content)).toContain( + 'I completed the goal by updating the feature and running the tests.', + ); // Completion is transient: it announces, then clears the durable record, so // the goal box disappears and nothing is left on disk. @@ -217,13 +223,15 @@ describe('goal session end-to-end', () => { name: 'UpdateGoal', arguments: JSON.stringify({ status: 'complete' }), }); + scripted.mockNextResponse({ type: 'text', text: 'I completed the resumed goal.' }); agent.turn.prompt([{ type: 'text', text: 'Keep working on the goal' }]); await agent.turn.waitForCurrentTurn(); - expect(scripted.calls.length).toBeGreaterThanOrEqual(3); + expect(scripted.calls.length).toBeGreaterThanOrEqual(4); expect(JSON.stringify(scripted.calls[0]?.history ?? [])).toContain('currently paused'); expect(JSON.stringify(scripted.calls[2]?.history ?? [])).toContain('Continue working toward the active goal'); + expect(JSON.stringify(scripted.calls[3]?.history ?? [])).toContain('summarize how you completed the goal'); expect(api.getGoal({}).goal).toBeNull(); }); From c01a5afb15195151421df5dec1c7b04d9a59e74c Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:04:56 +0800 Subject: [PATCH 03/25] fix: pause goals on runtime errors --- .changeset/pause-goals-on-model-errors.md | 6 ++ docs/en/guides/goals.md | 4 +- docs/zh/guides/goals.md | 4 +- packages/agent-core/src/agent/turn/index.ts | 44 ++++++++---- packages/agent-core/src/session/goal.ts | 44 ++++++------ .../test/harness/goal-session.test.ts | 71 ++++++++++++++++++- 6 files changed, 134 insertions(+), 39 deletions(-) create mode 100644 .changeset/pause-goals-on-model-errors.md diff --git a/.changeset/pause-goals-on-model-errors.md b/.changeset/pause-goals-on-model-errors.md new file mode 100644 index 000000000..d4117be11 --- /dev/null +++ b/.changeset/pause-goals-on-model-errors.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Pause goals after model, provider, or runtime errors instead of blocking them. diff --git a/docs/en/guides/goals.md b/docs/en/guides/goals.md index d029f31e4..bb44de0d1 100644 --- a/docs/en/guides/goals.md +++ b/docs/en/guides/goals.md @@ -107,8 +107,8 @@ Use the same command surface to inspect or control the current goal: A goal can stop in three ways: - **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, 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 +- **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 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 e6691d13b..374dc99fa 100644 --- a/docs/zh/guides/goals.md +++ b/docs/zh/guides/goals.md @@ -107,8 +107,8 @@ Kimi Code 会保存该目标,把它作为下一条用户消息发送,并进 目标有三种停止方式: - **完成(`complete`)**:目标已完成,Kimi Code 会清除该目标,Agent 会总结它如何完成了这项工作 -- **暂停(`paused`)**:你暂停了它、中断了当前轮次,或恢复了原本有目标的会话 -- **阻塞(`blocked`)**:Kimi Code 需要输入、无法按当前表述完成目标、达到预算上限,或遇到运行时失败 +- **暂停(`paused`)**:你暂停了它、中断了当前轮次、恢复了原本有目标的会话,或遇到模型、供应商或运行时错误 +- **阻塞(`blocked`)**:Kimi Code 需要输入、无法按当前表述完成目标,或达到预算上限 停止条件需要写在目标本身里。`/goal` 没有单独用于描述停止限制的语法。 diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 73d9a0a4c..55499ed2c 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -70,6 +70,11 @@ 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' }; 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 @@ -331,9 +336,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, @@ -364,13 +369,9 @@ export class TurnFlow { return end; } if (end.event.reason === 'failed') { - const pauseReason = goalFailurePauseReason(end.event.error); - if (pauseReason !== null) { - await this.agent.goals?.pauseActiveGoal({ actor: 'runtime', reason: pauseReason }); - return end; - } - await this.agent.goals?.markBlocked({ - reason: `Runtime error: ${end.event.error?.message ?? 'unknown'}`, + await this.agent.goals?.pauseActiveGoal({ + actor: 'runtime', + reason: goalFailurePauseReason(end.event.error), }); return end; } @@ -989,9 +990,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/session/goal.ts b/packages/agent-core/src/session/goal.ts index 3e926959d..417bc07af 100644 --- a/packages/agent-core/src/session/goal.ts +++ b/packages/agent-core/src/session/goal.ts @@ -33,8 +33,8 @@ export const MAX_GOAL_OBJECTIVE_LENGTH = 4000; * | Status | Persisted | Resumable | Set by | Meaning | * |------------|-----------|-----------|---------------------------------|--------------------------------------------------| * | `active` | yes | (running) | createGoal / resumeGoal | The goal driver may run continuation turns. | - * | `paused` | yes | yes | pauseGoal / pauseActiveGoal / | User, interrupt, resume, or retryable runtime | - * | | | | pauseOnInterrupt / | stop parked it; intact. | + * | `paused` | yes | yes | pauseGoal / pauseActiveGoal / | User, interrupt, resume, or runtime failure | + * | | | | pauseOnInterrupt / | parked it; intact. | * | | | | normalizeMetadata | | * | `blocked` | yes | yes | markBlocked | The system stopped it for some `reason`. | * | `complete` | no | — | markComplete | Success — announced in a message, then cleared. | @@ -45,10 +45,10 @@ export const MAX_GOAL_OBJECTIVE_LENGTH = 4000; * 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 SessionGoalStore} - * for the setters and the per-status notes below. + * unachievable goal or an exhausted budget becomes `blocked(+reason)`, + * runtime/model/provider failures become `paused(+reason)`, and `cancelGoal` + * discards the record entirely. See {@link SessionGoalStore} for the setters + * and the per-status notes below. */ export type GoalStatus = /** @@ -63,18 +63,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 * session is resumed from disk, where an `active` goal cannot still be running - * and is demoted (`normalizeMetadata`); or a retryable runtime stop such as a - * provider rate limit parked it via `pauseActiveGoal`. + * and is demoted (`normalizeMetadata`); 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. @@ -247,15 +246,16 @@ export interface SessionGoalStoreOptions { * The model marks completion via the `UpdateGoal('complete')` tool; the turn * driver reads the status at the turn boundary. `markComplete` announces, 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 `normalizeMetadata` demotes an `active` goal - * to `paused` on session resume. + * - 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 `normalizeMetadata` demotes an `active` goal to + * `paused` on session resume. */ export class SessionGoalStore { /** Audit records queued until the main-agent sink becomes available. */ @@ -511,7 +511,7 @@ export class SessionGoalStore { /** * 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 diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index b3da3a10d..fd808ec92 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -2,11 +2,12 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; -import { APIStatusError, type ProviderConfig } from '@moonshot-ai/kosong'; +import { APIConnectionError, APIStatusError, type ProviderConfig } from '@moonshot-ai/kosong'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ProviderManager } from '../../src/session/provider-manager'; import type { AgentOptions } from '../../src/agent'; +import { ErrorCodes, KimiError } from '../../src/errors'; import type { HookDef } from '../../src/session/hooks'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; @@ -252,6 +253,74 @@ describe('goal session end-to-end', () => { expect(goal?.terminalReason).toBe('Paused after provider rate limit'); }); + it('pauses the goal on provider connection errors', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const { session, agent } = await setupSession(sessionDir, events, ['GetGoal'], async () => { + throw new APIConnectionError('socket hang up'); + }); + const api = new SessionAPIImpl(session); + await api.createGoal({ objective: 'work' }); + + agent.turn.prompt([{ type: 'text', text: 'work' }]); + await agent.turn.waitForCurrentTurn(); + + const goal = api.getGoal({}).goal; + expect(goal?.status).toBe('paused'); + expect(goal?.terminalReason).toBe('Paused after provider connection error: socket hang up'); + }); + + it('pauses the goal on provider authentication errors', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const { session, agent } = await setupSession(sessionDir, events, ['GetGoal'], async () => { + throw new APIStatusError(401, 'Unauthorized', 'req-401'); + }); + const api = new SessionAPIImpl(session); + await api.createGoal({ objective: 'work' }); + + agent.turn.prompt([{ type: 'text', text: 'work' }]); + await agent.turn.waitForCurrentTurn(); + + const goal = api.getGoal({}).goal; + expect(goal?.status).toBe('paused'); + expect(goal?.terminalReason).toBe('Paused after provider authentication error: Unauthorized'); + }); + + it('pauses the goal on model configuration errors', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const { session, agent } = await setupSession(sessionDir, events, ['GetGoal'], async () => { + throw new KimiError(ErrorCodes.MODEL_NOT_CONFIGURED, 'Model not set'); + }); + const api = new SessionAPIImpl(session); + await api.createGoal({ objective: 'work' }); + + agent.turn.prompt([{ type: 'text', text: 'work' }]); + await agent.turn.waitForCurrentTurn(); + + const goal = api.getGoal({}).goal; + expect(goal?.status).toBe('paused'); + expect(goal?.terminalReason).toBe('Paused after model configuration error: LLM not set, send "/login" to login'); + }); + + it('pauses the goal on runtime errors', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const { session, agent } = await setupSession(sessionDir, events, ['GetGoal'], async () => { + throw new Error('unexpected failure'); + }); + const api = new SessionAPIImpl(session); + await api.createGoal({ objective: 'work' }); + + agent.turn.prompt([{ type: 'text', text: 'work' }]); + await agent.turn.waitForCurrentTurn(); + + const goal = api.getGoal({}).goal; + expect(goal?.status).toBe('paused'); + expect(goal?.terminalReason).toBe('Paused after runtime error: unexpected failure'); + }); + it('blocks the goal when the initial prompt hook blocks the objective', async () => { const sessionDir = await makeTempDir(); const events: Array> = []; From 1d2fb17e48fed6c76f248c325c924cefb444f533 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:33:54 +0800 Subject: [PATCH 04/25] fix: explain blocked goals in messages --- .changeset/show-blocked-goal-reasons.md | 6 ++++ docs/en/guides/goals.md | 2 +- docs/zh/guides/goals.md | 2 +- .../agent-core/src/agent/goal/completion.ts | 15 ++++++++ packages/agent-core/src/agent/turn/index.ts | 19 +++++----- .../src/tools/builtin/goal/update-goal.md | 2 +- .../src/tools/builtin/goal/update-goal.ts | 10 +++++- .../test/harness/goal-session.test.ts | 35 +++++++++++++++++++ packages/agent-core/test/tools/goal.test.ts | 6 ++-- 9 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 .changeset/show-blocked-goal-reasons.md diff --git a/.changeset/show-blocked-goal-reasons.md b/.changeset/show-blocked-goal-reasons.md new file mode 100644 index 000000000..23ce3caf2 --- /dev/null +++ b/.changeset/show-blocked-goal-reasons.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Ask the agent to explain blocked goals before goal mode stops. diff --git a/docs/en/guides/goals.md b/docs/en/guides/goals.md index bb44de0d1..c9fb3ef59 100644 --- a/docs/en/guides/goals.md +++ b/docs/en/guides/goals.md @@ -108,7 +108,7 @@ A goal can stop in three ways: - **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 +- **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 374dc99fa..be3923675 100644 --- a/docs/zh/guides/goals.md +++ b/docs/zh/guides/goals.md @@ -108,7 +108,7 @@ Kimi Code 会保存该目标,把它作为下一条用户消息发送,并进 - **完成(`complete`)**:目标已完成,Kimi Code 会清除该目标,Agent 会总结它如何完成了这项工作 - **暂停(`paused`)**:你暂停了它、中断了当前轮次、恢复了原本有目标的会话,或遇到模型、供应商或运行时错误 -- **阻塞(`blocked`)**:Kimi Code 需要输入、无法按当前表述完成目标,或达到预算上限 +- **阻塞(`blocked`)**:Kimi Code 需要输入、无法按当前表述完成目标,或达到预算上限。当 Agent 将目标标记为阻塞时,它会写一条简短消息说明原因。 停止条件需要写在目标本身里。`/goal` 没有单独用于描述停止限制的语法。 diff --git a/packages/agent-core/src/agent/goal/completion.ts b/packages/agent-core/src/agent/goal/completion.ts index 02c6b0857..3989f81c0 100644 --- a/packages/agent-core/src/agent/goal/completion.ts +++ b/packages/agent-core/src/agent/goal/completion.ts @@ -1,6 +1,7 @@ import type { GoalSnapshot } from '../../session/goal'; export const GOAL_COMPLETION_REMINDER_NAME = 'goal_completion'; +export const GOAL_BLOCKED_REMINDER_NAME = 'goal_blocked'; /** * The deterministic goal-completion message. It is built from the final @@ -22,6 +23,20 @@ export function buildGoalCompletionSummaryPrompt(goal: GoalSnapshot): string { ].join('\n'); } +export function buildGoalBlockedReasonPrompt(goal: GoalSnapshot): string { + return [ + buildGoalBlockedMessage(goal), + '', + 'Now explain why the goal is blocked for the user. Mention the concrete blocker and what input or change is needed before work can continue. Do not call more goal tools.', + ].join('\n'); +} + +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 `${totalSeconds}s`; diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 55499ed2c..a7f65d6c2 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -37,7 +37,7 @@ import type { AgentEvent, TurnEndedEvent } from '../../rpc'; import type { TelemetryPropertyValue } from '../../telemetry'; import { abortable, userCancellationReason } from '../../utils/abort'; import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; -import { GOAL_COMPLETION_REMINDER_NAME } from '../goal/completion'; +import { GOAL_BLOCKED_REMINDER_NAME, GOAL_COMPLETION_REMINDER_NAME } from '../goal/completion'; import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks'; import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args'; import { ToolCallDeduplicator } from './tool-dedup'; @@ -570,7 +570,7 @@ export class TurnFlow { private async runStepLoop(turnId: number, signal: AbortSignal): Promise { let stopHookContinuationUsed = false; - let goalCompletionSummaryContinuationUsed = 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 @@ -630,13 +630,13 @@ export class TurnFlow { if (this.flushSteerBuffer()) return { continue: true }; signal.throwIfAborted(); - // 2. After UpdateGoal marks a goal complete, ask the model for one - // final user-facing summary before the turn ends. + // 2. After UpdateGoal marks a goal terminal, ask the model for one + // final user-facing outcome message before the turn ends. if ( - !goalCompletionSummaryContinuationUsed && - isGoalCompletionReminder(this.agent.context.history.at(-1)) + !goalOutcomeMessageContinuationUsed && + isGoalOutcomeReminder(this.agent.context.history.at(-1)) ) { - goalCompletionSummaryContinuationUsed = true; + goalOutcomeMessageContinuationUsed = true; return { continue: true }; } @@ -874,10 +874,11 @@ export class TurnFlow { } } -function isGoalCompletionReminder(message: { readonly origin?: PromptOrigin | undefined } | undefined): boolean { +function isGoalOutcomeReminder(message: { readonly origin?: PromptOrigin | undefined } | undefined): boolean { return ( message?.origin?.kind === 'system_trigger' && - message.origin.name === GOAL_COMPLETION_REMINDER_NAME + (message.origin.name === GOAL_COMPLETION_REMINDER_NAME || + message.origin.name === GOAL_BLOCKED_REMINDER_NAME) ); } 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 a6095607c..6039ad207 100644 --- a/packages/agent-core/src/tools/builtin/goal/update-goal.ts +++ b/packages/agent-core/src/tools/builtin/goal/update-goal.ts @@ -14,7 +14,9 @@ import type { Agent } from '#/agent'; import { z } from 'zod'; import { + GOAL_BLOCKED_REMINDER_NAME, GOAL_COMPLETION_REMINDER_NAME, + buildGoalBlockedReasonPrompt, buildGoalCompletionSummaryPrompt, } from '../../../agent/goal/completion'; import type { BuiltinTool } from '../../../agent/tool'; @@ -70,7 +72,13 @@ export class UpdateGoalTool implements BuiltinTool { return { output: 'Goal marked complete.', stopTurn: true }; } if (args.status === 'blocked') { - await store.markBlocked({ actor: 'model' }); + const blocked = await store.markBlocked({ actor: '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 store.pauseGoal({ actor: 'model' }); diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index fd808ec92..11ec87a8b 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -236,6 +236,41 @@ describe('goal session end-to-end', () => { expect(api.getGoal({}).goal).toBeNull(); }); + it('asks the model to explain why it marked a goal blocked', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const { session, agent, scripted } = await setupSession(sessionDir, events, ['GetGoal', 'UpdateGoal']); + const api = new SessionAPIImpl(session); + await api.createGoal({ objective: 'work' }); + + scripted.mockNextResponse({ + type: 'function', + id: 'blocked', + name: 'UpdateGoal', + arguments: JSON.stringify({ status: 'blocked' }), + }); + scripted.mockNextResponse({ + type: 'text', + text: 'I blocked the goal because credentials are required before I can continue.', + }); + + agent.turn.prompt([{ type: 'text', text: 'work' }]); + await agent.turn.waitForCurrentTurn(); + + expect(scripted.calls).toHaveLength(2); + const reasonHistory = JSON.stringify(scripted.calls[1]?.history ?? []); + expect(reasonHistory).toContain('Goal blocked.'); + expect(reasonHistory).toContain('explain why the goal is blocked'); + const lastContextMessage = agent.context.history.at(-1); + expect(lastContextMessage?.role).toBe('assistant'); + expect(JSON.stringify(lastContextMessage?.content)).toContain( + 'I blocked the goal because credentials are required before I can continue.', + ); + const goal = api.getGoal({}).goal; + expect(goal?.status).toBe('blocked'); + expect(goal?.terminalReason).toBeUndefined(); + }); + it('pauses the goal on provider rate limits', async () => { const sessionDir = await makeTempDir(); const events: Array> = []; diff --git a/packages/agent-core/test/tools/goal.test.ts b/packages/agent-core/test/tools/goal.test.ts index 3c458cd4e..9188aa816 100644 --- a/packages/agent-core/test/tools/goal.test.ts +++ b/packages/agent-core/test/tools/goal.test.ts @@ -217,8 +217,8 @@ describe('SetGoalBudgetTool', () => { }); describe('UpdateGoalTool', () => { - // The complete path appends the completion line as a system reminder, so the - // agent needs a context exposing appendSystemReminder. + // Terminal paths append follow-up reminders, so the agent needs a context + // exposing appendSystemReminder. function agentWithContext(store: SessionGoalStore): Agent { return { type: 'main', @@ -231,6 +231,7 @@ describe('UpdateGoalTool', () => { for (const status of ['active', 'complete', 'paused', 'blocked']) { expect(UpdateGoalToolInputSchema.safeParse({ status }).success).toBe(true); } + expect(UpdateGoalToolInputSchema.safeParse({ status: 'blocked', reason: 'x' }).success).toBe(false); for (const status of ['impossible', 'cancelled', '']) { expect(UpdateGoalToolInputSchema.safeParse({ status }).success).toBe(false); } @@ -257,6 +258,7 @@ describe('UpdateGoalTool', () => { ); expect(result.stopTurn).toBe(true); expect(store.getGoal().goal?.status).toBe('blocked'); + expect(store.getGoal().goal?.terminalReason).toBeUndefined(); }); it('`paused` marks the goal paused', async () => { From c5e7124bafd058383dbc434cfe1af7338e93f320 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:07:00 +0800 Subject: [PATCH 05/25] fix: suppress duplicate blocked goal marker --- .../src/tui/controllers/session-event-handler.ts | 1 + .../session-event-handler-goal-queue.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) 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 24f866ae1..0bcfe9aba 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -598,6 +598,7 @@ export class SessionEventHandler { // ctrl+o-expandable marker. if (change.kind === 'lifecycle' && change.status === 'blocked') { void this.notifyQueuedGoalWaitingOnBlocked(); + if (event.snapshot?.updatedBy === 'model') return; } const marker = buildGoalMarker(change, state.theme.colors, state.toolOutputExpanded); if (marker !== null) { 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 e3fa47500..966a02e99 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 @@ -354,6 +354,22 @@ 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); + const event = { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot: fakeGoalSnapshot('Blocked goal', 'blocked'), + change: { kind: 'lifecycle', status: 'blocked' }, + } as const; + + handler.handleEvent(event, 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); From cdd7c9ed353af5d87220894bd58b3b91ffe2e97a Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:08:11 +0800 Subject: [PATCH 06/25] fix: show goal cancel as notice --- apps/kimi-code/src/tui/commands/goal.ts | 2 +- apps/kimi-code/test/tui/commands/goal.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index 79c7efdd4..fc24e0bde 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -478,7 +478,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/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index 9b55fbe3b..cd7599bb0 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -623,6 +623,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(); }); From e596992894a8a0d02b14c866eb058756167ba29b Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:09:20 +0800 Subject: [PATCH 07/25] fix: align goal report header colors --- .../messages/tool-renderers/goal.ts | 7 ++++- .../tui/components/messages/tool-call.test.ts | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) 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 index ccfb08173..86b372605 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -1,6 +1,7 @@ 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'; @@ -57,12 +58,16 @@ export function buildGoalToolHeader(options: { const tone = result?.is_error === true ? colors.error : colors.primary; const label = chalk.hex(tone).bold(goalToolLabel(toolCall.name, result, toolCall.args)); + const marker = + toolCall.name === 'UpdateGoal' && 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 `${bullet}${label}${argText}${chip}`; + return `${marker}${label}${argText}${chip}`; } export function formatGoalBudgetArg(args: Record): string | undefined { 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 3f4ad52c8..38b5d4071 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'; @@ -577,6 +578,34 @@ describe('ToolCallComponent', () => { 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( { From e89573c4f8ebc7c5dcd0be58ce6e477a9457a0f0 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:11:49 +0800 Subject: [PATCH 08/25] fix: emphasize goal pause resume markers --- .changeset/polish-goal-transcript-markers.md | 5 ++ .../tui/components/messages/goal-markers.ts | 85 ++++++++++++++++--- .../tui/controllers/session-event-handler.ts | 7 +- .../components/messages/goal-markers.test.ts | 18 ++++ 4 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 .changeset/polish-goal-transcript-markers.md diff --git a/.changeset/polish-goal-transcript-markers.md b/.changeset/polish-goal-transcript-markers.md new file mode 100644 index 000000000..60e1ce1be --- /dev/null +++ b/.changeset/polish-goal-transcript-markers.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Polish goal transcript messages for blocked, cancelled, paused, and resumed goals. 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..6c7231dc2 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,40 @@ 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; +} + export class GoalMarkerComponent implements Component { private expanded = false; + private readonly marker: string; + private readonly textHex: string; + private readonly expandable: boolean; + private readonly indent: string; 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; + } invalidate(): void {} @@ -33,15 +53,18 @@ 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.indent}${dot} ${head}`]; + if (!this.expandable) { + return [`${this.indent}${dot} ${head}`]; + } if (!this.expanded) { - return [`${HEAD_INDENT}${dot} ${head} ${chalk.hex(this.colors.textMuted)('(ctrl+o)')}`]; + return [`${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)); @@ -59,10 +82,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 +100,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 +123,33 @@ 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: '', + }, + }; +} + +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 !== undefined && reason.length > 0) return `Goal paused: ${reason}`; + 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 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/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 0bcfe9aba..80b57bb77 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -600,7 +600,12 @@ export class SessionEventHandler { void this.notifyQueuedGoalWaitingOnBlocked(); if (event.snapshot?.updatedBy === 'model') return; } - const marker = buildGoalMarker(change, state.theme.colors, state.toolOutputExpanded); + const marker = buildGoalMarker( + change, + state.theme.colors, + state.toolOutputExpanded, + event.snapshot?.updatedBy, + ); if (marker !== null) { state.transcriptContainer.addChild(marker); state.ui.requestRender(); 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..12b610f2e 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,24 @@ 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("● Goal paused due to user's interruption"); + expect(strip(resumed!.render(80))).toBe('● Goal resumed by the user.'); + }); + it('returns null for a completion change (it posts its own message)', () => { expect( buildGoalMarker({ kind: 'completion', status: 'complete' } as GoalChange, darkColors, false), From ab52441521e58561b2795e7dcecc9ca2c0638a8d Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:45:13 +0800 Subject: [PATCH 09/25] fix: space goal lifecycle markers --- .../tui/components/messages/goal-markers.ts | 18 ++++++++++++++---- .../components/messages/goal-markers.test.ts | 7 +++++-- 2 files changed, 19 insertions(+), 6 deletions(-) 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 6c7231dc2..48c6d17f9 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -24,6 +24,7 @@ interface GoalMarkerOptions { readonly textHex?: string; readonly expandable?: boolean; readonly indent?: string; + readonly leadingBlank?: boolean; } export class GoalMarkerComponent implements Component { @@ -32,6 +33,7 @@ export class GoalMarkerComponent implements Component { private readonly textHex: string; private readonly expandable: boolean; private readonly indent: string; + private readonly leadingBlank: boolean; constructor( private readonly headline: string, @@ -44,6 +46,7 @@ export class GoalMarkerComponent implements Component { this.textHex = options.textHex ?? colors.textDim; this.expandable = options.expandable ?? true; this.indent = options.indent ?? HEAD_INDENT; + this.leadingBlank = options.leadingBlank ?? false; } invalidate(): void {} @@ -56,20 +59,26 @@ export class GoalMarkerComponent implements Component { 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 [`${this.indent}${dot} ${head}`]; + if (!hasDetail) return this.withLeadingBlank([`${this.indent}${dot} ${head}`]); if (!this.expandable) { - return [`${this.indent}${dot} ${head}`]; + return this.withLeadingBlank([`${this.indent}${dot} ${head}`]); } if (!this.expanded) { - return [`${this.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 = [`${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; } } @@ -133,6 +142,7 @@ function prominentMarker(headline: string, accentHex: string) { textHex: accentHex, expandable: false, indent: '', + leadingBlank: true, }, }; } 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 12b610f2e..a1048cd3e 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 @@ -33,8 +33,11 @@ describe('buildGoalMarker', () => { 'user', ); - expect(strip(paused!.render(80))).toBe("● Goal paused due to user's interruption"); - expect(strip(resumed!.render(80))).toBe('● Goal resumed by the 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('returns null for a completion change (it posts its own message)', () => { From 622e378ed8ea92b2046eae100a24bb2b5a8165ea Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:45:59 +0800 Subject: [PATCH 10/25] fix: avoid duplicate goal resume status --- apps/kimi-code/src/tui/commands/goal.ts | 1 - apps/kimi-code/test/tui/commands/goal.test.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index fc24e0bde..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); } diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index cd7599bb0..994f5f019 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -616,6 +616,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.'); }); From 6b39226e2b6db2be039aa34e50a7207349b4df97 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:12:56 +0800 Subject: [PATCH 11/25] fix: strengthen goal outcome prompts --- .changeset/strengthen-goal-outcome-prompts.md | 6 ++++++ .../agent-core/src/agent/goal/completion.ts | 13 +++++++++--- .../test/agent/goal-completion.test.ts | 21 ++++++++++++++++++- .../test/harness/goal-session.test.ts | 8 +++---- 4 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 .changeset/strengthen-goal-outcome-prompts.md diff --git a/.changeset/strengthen-goal-outcome-prompts.md b/.changeset/strengthen-goal-outcome-prompts.md new file mode 100644 index 000000000..c13ba2ad4 --- /dev/null +++ b/.changeset/strengthen-goal-outcome-prompts.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Strengthen goal outcome prompts sent to the agent while preserving user-visible goal messages. diff --git a/packages/agent-core/src/agent/goal/completion.ts b/packages/agent-core/src/agent/goal/completion.ts index 3989f81c0..b8d20f541 100644 --- a/packages/agent-core/src/agent/goal/completion.ts +++ b/packages/agent-core/src/agent/goal/completion.ts @@ -17,9 +17,9 @@ export function buildGoalCompletionMessage(goal: GoalSnapshot): string { export function buildGoalCompletionSummaryPrompt(goal: GoalSnapshot): string { return [ - buildGoalCompletionMessage(goal), + buildGoalCompletionPromptMessage(goal), '', - 'Now summarize how you completed the goal for the user. Mention the main work completed and any validation you ran. Do not call more goal tools.', + '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'); } @@ -27,10 +27,17 @@ export function buildGoalBlockedReasonPrompt(goal: GoalSnapshot): string { return [ buildGoalBlockedMessage(goal), '', - 'Now explain why the goal is blocked for the user. Mention the concrete blocker and what input or change is needed before work can continue. Do not call more goal tools.', + '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.`; diff --git a/packages/agent-core/test/agent/goal-completion.test.ts b/packages/agent-core/test/agent/goal-completion.test.ts index 42e824aec..1cce435c9 100644 --- a/packages/agent-core/test/agent/goal-completion.test.ts +++ b/packages/agent-core/test/agent/goal-completion.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { buildGoalCompletionMessage } from '#/agent/goal/completion'; +import { + buildGoalBlockedReasonPrompt, + buildGoalCompletionMessage, + buildGoalCompletionSummaryPrompt, +} from '#/agent/goal/completion'; import type { GoalSnapshot } from '#/session/goal'; function snapshot(overrides: Partial = {}): GoalSnapshot { @@ -32,4 +36,19 @@ describe('buildGoalCompletionMessage', () => { expect(text).toContain('800 tokens'); expect(text).toContain('5s'); }); + + 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/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index 11ec87a8b..df72e0f32 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -153,8 +153,8 @@ describe('goal session end-to-end', () => { // Terminal UpdateGoal asks the model for one final user-facing summary. expect(scripted.calls).toHaveLength(3); const summaryHistory = JSON.stringify(scripted.calls[2]?.history ?? []); - expect(summaryHistory).toContain('Goal complete.'); - expect(summaryHistory).toContain('summarize how you completed the goal'); + expect(summaryHistory).toContain('Goal completed successfully.'); + expect(summaryHistory).toContain('Write a concise final message for the user'); const lastContextMessage = agent.context.history.at(-1); expect(lastContextMessage?.role).toBe('assistant'); expect(JSON.stringify(lastContextMessage?.content)).toContain( @@ -232,7 +232,7 @@ describe('goal session end-to-end', () => { expect(scripted.calls.length).toBeGreaterThanOrEqual(4); expect(JSON.stringify(scripted.calls[0]?.history ?? [])).toContain('currently paused'); expect(JSON.stringify(scripted.calls[2]?.history ?? [])).toContain('Continue working toward the active goal'); - expect(JSON.stringify(scripted.calls[3]?.history ?? [])).toContain('summarize how you completed the goal'); + expect(JSON.stringify(scripted.calls[3]?.history ?? [])).toContain('Write a concise final message for the user'); expect(api.getGoal({}).goal).toBeNull(); }); @@ -260,7 +260,7 @@ describe('goal session end-to-end', () => { expect(scripted.calls).toHaveLength(2); const reasonHistory = JSON.stringify(scripted.calls[1]?.history ?? []); expect(reasonHistory).toContain('Goal blocked.'); - expect(reasonHistory).toContain('explain why the goal is blocked'); + expect(reasonHistory).toContain('State that the goal is blocked'); const lastContextMessage = agent.context.history.at(-1); expect(lastContextMessage?.role).toBe('assistant'); expect(JSON.stringify(lastContextMessage?.content)).toContain( From f28b8ee99928e8ff5759b730060c3d542fe27ab6 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:47:12 +0800 Subject: [PATCH 12/25] fix: avoid duplicate goal budget header --- .../src/tui/components/messages/tool-renderers/chip.ts | 6 +----- .../test/tui/components/messages/tool-call.test.ts | 1 + .../tui/components/messages/tool-renderers/chip.test.ts | 6 ++---- 3 files changed, 4 insertions(+), 9 deletions(-) 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 74f714ee0..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,7 +11,7 @@ import { computeDiffLines } from '#/tui/components/media/diff-preview'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; -import { formatGoalBudgetArg, goalStatusChip } from './goal'; +import { goalStatusChip } from './goal'; import { readMediaChip } from './media'; import { strArg } from './types'; @@ -114,9 +114,6 @@ const webSearchChip: ChipProvider = (_toolCall, result) => { const goalStatusOutputChip: ChipProvider = (_toolCall, result) => result.is_error ? '' : goalStatusChip(result.output); -const goalBudgetChip: ChipProvider = (toolCall, result) => - result.is_error ? '' : (formatGoalBudgetArg(toolCall.args) ?? ''); - const REGISTRY: Record = { Edit: editChip, Write: writeChip, @@ -128,7 +125,6 @@ const REGISTRY: Record = { WebSearch: webSearchChip, CreateGoal: goalStatusOutputChip, GetGoal: goalStatusOutputChip, - SetGoalBudget: goalBudgetChip, }; export function pickChip(toolName: string): ChipProvider | undefined { 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 38b5d4071..2ea2a1157 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 @@ -551,6 +551,7 @@ describe('ToolCallComponent', () => { 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.'); }); 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 2ccd3324f..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 @@ -92,10 +92,8 @@ describe('chip registry', () => { expect(chipFor('CreateGoal', { objective: 'Ship feature X' }, result('{"goal":{"status":"active"}}'))).toBe('active'); }); - it('SetGoalBudget chip shows the configured budget', () => { - expect(chipFor('SetGoalBudget', { value: 10, unit: 'turns' }, result('Goal budget set.'))).toBe( - '10 turns', - ); + 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', () => { From 0dba3815e416123360678c1675be442ba04dc9d7 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:48:01 +0800 Subject: [PATCH 13/25] fix: color goal budget marker consistently --- .../messages/tool-renderers/goal.ts | 2 +- .../tui/components/messages/tool-call.test.ts | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) 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 index 86b372605..f7717a3db 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -59,7 +59,7 @@ export function buildGoalToolHeader(options: { const tone = result?.is_error === true ? colors.error : colors.primary; const label = chalk.hex(tone).bold(goalToolLabel(toolCall.name, result, toolCall.args)); const marker = - toolCall.name === 'UpdateGoal' && result !== undefined && result.is_error !== true + result !== undefined && result.is_error !== true ? chalk.hex(colors.primary)(STATUS_BULLET) : bullet; const arg = 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 2ea2a1157..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 @@ -556,6 +556,32 @@ describe('ToolCallComponent', () => { 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( { From 9e47c6f693d1a23701a9e879cccd84b374cbd1f4 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:49:17 +0800 Subject: [PATCH 14/25] fix: hide goal outcome prompts on replay --- .../src/tui/controllers/session-replay.ts | 25 +++++++----- .../kimi-code/test/tui/message-replay.test.ts | 38 +++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 5e87fdd11..418ba1de0 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -245,12 +245,14 @@ export class SessionReplayRenderer { this.renderCronMissed(context, message); return; } - const goalCompletion = goalCompletionFromSystemReminder(message); - if (goalCompletion !== null) { - this.flushAssistant(context); - this.host.appendTranscriptEntry( - replayEntry(context, 'assistant', goalCompletion, 'markdown'), - ); + const goalReminder = goalOutcomeReminderFromSystemMessage(message); + if (goalReminder !== null) { + if (goalReminder !== undefined) { + this.flushAssistant(context); + this.host.appendTranscriptEntry( + replayEntry(context, 'assistant', goalReminder, 'markdown'), + ); + } return; } @@ -553,13 +555,18 @@ export class SessionReplayRenderer { } } -function goalCompletionFromSystemReminder(message: ContextMessage): string | null { - if (message.origin?.kind !== 'system_trigger' || message.origin.name !== 'goal_completion') { +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; } const text = contentPartsToText(message.content); const match = /^\n([\s\S]*)\n<\/system-reminder>$/.exec(text); - return match?.[1] ?? text; + const reminder = match?.[1] ?? text; + if (message.origin.name === 'goal_completion' && reminder.trimStart().startsWith('✓ Goal complete')) { + return reminder; + } + return undefined; } function extractCronPrompt(text: string): string { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 9d21b927e..3111a8012 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -261,6 +261,44 @@ describe('KimiTUI resume message replay', () => { }); }); + 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('groups replayed Agent calls from one assistant message using live grouping', async () => { const replay: AgentReplayRecord[] = [ message('user', [{ type: 'text', text: 'run two agents' }]), From 4f54a783e25a399c752bb6546305e38aea802aa3 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:51:41 +0800 Subject: [PATCH 15/25] fix: respect goal summary step limits --- packages/agent-core/src/agent/turn/index.ts | 18 ++++++-- .../test/harness/goal-session.test.ts | 46 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index a7f65d6c2..6e7cd69af 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -637,6 +637,10 @@ export class TurnFlow { isGoalOutcomeReminder(this.agent.context.history.at(-1)) ) { goalOutcomeMessageContinuationUsed = true; + if (!hasStepBudgetRemaining(loopControl?.maxStepsPerTurn, ctx.stepNumber)) { + this.agent.context.popMatchedMessage(isGoalOutcomeReminderOrigin); + return { continue: false }; + } return { continue: true }; } @@ -875,13 +879,21 @@ export class TurnFlow { } function isGoalOutcomeReminder(message: { readonly origin?: PromptOrigin | undefined } | undefined): boolean { + return isGoalOutcomeReminderOrigin(message?.origin); +} + +function isGoalOutcomeReminderOrigin(origin: PromptOrigin | undefined): boolean { return ( - message?.origin?.kind === 'system_trigger' && - (message.origin.name === GOAL_COMPLETION_REMINDER_NAME || - message.origin.name === GOAL_BLOCKED_REMINDER_NAME) + 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': diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index df72e0f32..327143fcd 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ProviderManager } from '../../src/session/provider-manager'; import type { AgentOptions } from '../../src/agent'; +import type { KimiConfig } from '../../src/config'; import { ErrorCodes, KimiError } from '../../src/errors'; import type { HookDef } from '../../src/session/hooks'; import type { ResolvedAgentProfile } from '../../src/profile'; @@ -85,6 +86,7 @@ async function setupSession( tools: readonly string[], generate?: NonNullable, hooks?: readonly HookDef[], + config?: KimiConfig, ) { const scripted = createScriptedGenerate(); const session = track( @@ -96,6 +98,7 @@ async function setupSession( skills: { explicitDirs: [join(sessionDir, 'missing')] }, providerManager: testProviderManager(), hooks, + config, }), ); const { agent } = await session.createAgent( @@ -271,6 +274,49 @@ describe('goal session end-to-end', () => { expect(goal?.terminalReason).toBeUndefined(); }); + it('does not force a goal outcome summary after maxStepsPerTurn is exhausted', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const { session, agent, scripted } = await setupSession( + sessionDir, + events, + ['GetGoal', 'UpdateGoal'], + undefined, + undefined, + { providers: {}, loopControl: { maxStepsPerTurn: 1 } }, + ); + const api = new SessionAPIImpl(session); + await api.createGoal({ objective: 'work' }); + + scripted.mockNextResponse({ + type: 'function', + id: 'complete', + name: 'UpdateGoal', + arguments: JSON.stringify({ status: 'complete' }), + }); + scripted.mockNextResponse({ type: 'text', text: 'This summary should not run.' }); + + agent.turn.prompt([{ type: 'text', text: 'work' }]); + await agent.turn.waitForCurrentTurn(); + + expect(scripted.calls).toHaveLength(1); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + reason: 'completed', + }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + reason: 'failed', + error: expect.objectContaining({ code: ErrorCodes.LOOP_MAX_STEPS_EXCEEDED }), + }), + ); + expect(api.getGoal({}).goal).toBeNull(); + expect(JSON.stringify(agent.context.history)).not.toContain('Write a concise final message'); + }); + it('pauses the goal on provider rate limits', async () => { const sessionDir = await makeTempDir(); const events: Array> = []; From 943f3332408bf2bc8c6d4382ac8b984b6cf8b2bf Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:10:57 +0800 Subject: [PATCH 16/25] fix: replay deterministic goal completion stats --- .../tui/controllers/session-event-handler.ts | 4 +-- .../src/tui/controllers/session-replay.ts | 6 +++++ .../kimi-code/test/tui/message-replay.test.ts | 18 +++++++++++++ .../agent-core/src/agent/goal/completion.ts | 11 ++++++++ .../agent-core/src/agent/records/index.ts | 26 ++++++++++++++++--- packages/agent-core/src/rpc/resumed.ts | 1 + packages/agent-core/test/agent/resume.test.ts | 24 +++++++++++++++++ 7 files changed, 84 insertions(+), 6 deletions(-) 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 80b57bb77..d36daa076 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -579,8 +579,8 @@ export class SessionEventHandler { // Completion -> the box disappears (snapshot cleared on the follow-up null // update) and a deterministic completion message lands in the transcript. - // The same text is appended to the conversation by the continuation - // controller, so it persists and renders identically on resume. + // The model-facing follow-up prompt stays in context without the checkmark; + // resume rebuilds this deterministic card from the goal.update audit stats. if (change.kind === 'completion' && event.snapshot !== null) { this.goalCompletionAwaitingClear = true; this.goalCompletionTurnEnded = false; diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 418ba1de0..e54f9657a 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -173,6 +173,12 @@ export class SessionReplayRenderer { case 'message': this.renderMessage(context, record.message); return; + case 'goal_completion': + this.flushAssistant(context); + this.host.appendTranscriptEntry( + replayEntry(context, 'assistant', record.content, 'markdown'), + ); + return; case 'plan_updated': this.flushAssistant(context); if (!record.enabled && context.suppressNextPlanModeOffNotice) { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 3111a8012..c420bd263 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -261,6 +261,24 @@ describe('KimiTUI resume message replay', () => { }); }); + it('renders replayed goal completion records as assistant completion messages', async () => { + const driver = await replayIntoDriver([ + { + type: 'goal_completion', + content: '✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.3M tokens.', + }, + ]); + + 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( diff --git a/packages/agent-core/src/agent/goal/completion.ts b/packages/agent-core/src/agent/goal/completion.ts index b8d20f541..815fb18b2 100644 --- a/packages/agent-core/src/agent/goal/completion.ts +++ b/packages/agent-core/src/agent/goal/completion.ts @@ -3,12 +3,23 @@ import type { GoalSnapshot } from '../../session/goal'; export const GOAL_COMPLETION_REMINDER_NAME = 'goal_completion'; export const GOAL_BLOCKED_REMINDER_NAME = 'goal_blocked'; +interface GoalCompletionStats { + readonly terminalReason?: string | undefined; + readonly turnsUsed: number; + readonly tokensUsed: number; + readonly wallClockMs: number; +} + /** * The deterministic goal-completion message. It is built from the final * snapshot — not the model — so the figures (turns / tokens / time) are * guaranteed exact. */ 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/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index aa9f1a28a..114508fed 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -1,4 +1,5 @@ import type { Agent } from '..'; +import { buildGoalCompletionMessageFromStats } from '../goal/completion'; import { AGENT_WIRE_PROTOCOL_VERSION, isNewerWireVersion, @@ -108,15 +109,32 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { case 'tools.update_store': agent.tools.updateStore(input.key, input.value); return; - // TODO: Move goal state transitions to real resume semantics. These records - // are currently audit-only, while goal state is restored from `state.json` - // (metadata.custom.goal) instead of being rebuilt from ordered records. + // TODO: Move goal state transitions to real resume semantics. Goal state is + // restored from `state.json` (metadata.custom.goal) instead of being rebuilt + // from ordered records; completion updates also feed a replay-only UI card. case 'goal.create': - case 'goal.update': case 'goal.account_usage': case 'goal.continuation': case 'goal.clear': return; + case 'goal.update': + if ( + input.status === 'complete' && + input.turnsUsed !== undefined && + input.tokensUsed !== undefined && + input.wallClockMs !== undefined + ) { + agent.replayBuilder.push({ + type: 'goal_completion', + content: buildGoalCompletionMessageFromStats({ + terminalReason: input.reason, + turnsUsed: input.turnsUsed, + tokensUsed: input.tokensUsed, + wallClockMs: input.wallClockMs, + }), + }); + } + return; } } diff --git a/packages/agent-core/src/rpc/resumed.ts b/packages/agent-core/src/rpc/resumed.ts index 897471154..8b24bf2f7 100644 --- a/packages/agent-core/src/rpc/resumed.ts +++ b/packages/agent-core/src/rpc/resumed.ts @@ -15,6 +15,7 @@ import type { SessionMeta } from '#/session'; export type AgentReplayRecord = | { type: 'message'; message: ContextMessage } + | { type: 'goal_completion'; content: string } | { type: 'plan_updated'; enabled: boolean } | { type: 'config_updated'; config: AgentConfigUpdateData } | { type: 'permission_updated'; mode: PermissionMode } diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index 7e64f0e80..815b594d9 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -323,6 +323,30 @@ describe('Agent resume', () => { }); }); + it('rebuilds goal completion replay cards without adding model-visible context', async () => { + const persistence = new RecordingAgentPersistence([ + { + type: 'goal.update', + goalId: 'goal-1', + status: 'complete', + actor: 'model', + reason: 'all tests passed', + turnsUsed: 2, + tokensUsed: 1200, + wallClockMs: 65_000, + }, + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.resume(); + + expect(ctx.agent.context.history).toHaveLength(0); + expect(ctx.agent.replayBuilder.buildResult()).toContainEqual({ + type: 'goal_completion', + content: '✓ Goal complete — all tests passed.\nWorked 2 turns over 1m05s, using 1.2k tokens.', + }); + }); + it('removes replay messages matching undone history', async () => { const persistence = new RecordingAgentPersistence([ { From efd7fa912af1de9d6fd24f250aec9ad86d69a346 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:12:59 +0800 Subject: [PATCH 17/25] fix: avoid repeated paused marker wording --- .../src/tui/components/messages/goal-markers.ts | 5 +++++ .../test/tui/components/messages/goal-markers.test.ts | 11 +++++++++++ 2 files changed, 16 insertions(+) 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 48c6d17f9..89b52df0f 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -150,6 +150,7 @@ function prominentMarker(headline: string, accentHex: string) { 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}`; return 'Goal paused'; } @@ -160,6 +161,10 @@ function resumedHeadline(actor: GoalMarkerActor | undefined): string { 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/test/tui/components/messages/goal-markers.test.ts b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts index a1048cd3e..4105c6cb7 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 @@ -40,6 +40,17 @@ describe('buildGoalMarker', () => { ); }); + 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('returns null for a completion change (it posts its own message)', () => { expect( buildGoalMarker({ kind: 'completion', status: 'complete' } as GoalChange, darkColors, false), From c8dc3fc9becf4b698050a7f180ca70d8683c7ade Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:16:18 +0800 Subject: [PATCH 18/25] fix: show fallback for unexplained blocked goals --- .../tui/controllers/session-event-handler.ts | 39 ++++++++++++- .../session-event-handler-goal-queue.test.ts | 58 ++++++++++++++++--- 2 files changed, 88 insertions(+), 9 deletions(-) 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 d36daa076..d95161864 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,7 @@ export class SessionEventHandler { mcpServers: Map = new Map(); private goalCompletionAwaitingClear = false; private goalCompletionTurnEnded = false; + private pendingModelBlockedFallback: GoalChange | undefined; private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; @@ -148,6 +150,7 @@ export class SessionEventHandler { this.mcpServers.clear(); this.goalCompletionAwaitingClear = false; this.goalCompletionTurnEnded = false; + this.pendingModelBlockedFallback = undefined; this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); @@ -324,6 +327,7 @@ export class SessionEventHandler { } this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeTurn(sendQueued); + this.renderPendingModelBlockedFallback(); this.goalCompletionTurnEnded = true; this.scheduleQueuedGoalPromotion(); } @@ -415,6 +419,9 @@ export class SessionEventHandler { streamingUI.flushThinkingToTranscript('idle'); } + if (event.delta.trim().length > 0) { + this.pendingModelBlockedFallback = undefined; + } streamingUI.appendAssistantDelta(event.delta); this.host.patchLivePane({ @@ -434,6 +441,9 @@ export class SessionEventHandler { this.host.streamingUI.flushThinkingToTranscript('idle'); } this.host.streamingUI.finalizeAssistantStream(); + if (event.content.trim().length > 0) { + this.pendingModelBlockedFallback = undefined; + } this.host.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'assistant', @@ -573,6 +583,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 +595,7 @@ export class SessionEventHandler { // The model-facing follow-up prompt stays in context without the checkmark; // resume rebuilds this deterministic card from the goal.update audit stats. if (change.kind === 'completion' && event.snapshot !== null) { + this.pendingModelBlockedFallback = undefined; this.goalCompletionAwaitingClear = true; this.goalCompletionTurnEnded = false; this.host.appendTranscriptEntry({ @@ -598,7 +612,13 @@ export class SessionEventHandler { // ctrl+o-expandable marker. if (change.kind === 'lifecycle' && change.status === 'blocked') { void this.notifyQueuedGoalWaitingOnBlocked(); - if (event.snapshot?.updatedBy === 'model') return; + if (event.snapshot?.updatedBy === 'model') { + this.pendingModelBlockedFallback = change; + return; + } + this.pendingModelBlockedFallback = undefined; + } else if (change.kind === 'lifecycle') { + this.pendingModelBlockedFallback = undefined; } const marker = buildGoalMarker( change, @@ -612,6 +632,23 @@ export class SessionEventHandler { } } + 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(); + } + } + private scheduleQueuedGoalPromotion(): void { if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; if (this.queuedGoalPromotionInFlight) return; 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 966a02e99..1634646a3 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 @@ -72,6 +72,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(), @@ -139,6 +143,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(); @@ -357,15 +376,38 @@ describe('SessionEventHandler goal queue promotion', () => { it('does not render a duplicate marker for a model-reported blocked goal', () => { const { host } = makeHost(); const handler = new SessionEventHandler(host); - const event = { - type: 'goal.updated', - sessionId: 's1', - agentId: 'main', - snapshot: fakeGoalSnapshot('Blocked goal', 'blocked'), - change: { kind: 'lifecycle', status: 'blocked' }, - } as const; - handler.handleEvent(event, vi.fn()); + 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(); }); From 31dbce303017fde313b3cb72c988d220b6397007 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:16:44 +0800 Subject: [PATCH 19/25] fix: attribute model goal pauses --- .../tui/components/messages/goal-markers.ts | 1 + .../components/messages/goal-markers.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) 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 89b52df0f..97bd2c2c8 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -152,6 +152,7 @@ function pausedHeadline(reason: string | undefined, actor: GoalMarkerActor | und 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'; } 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 4105c6cb7..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 @@ -51,6 +51,24 @@ describe('buildGoalMarker', () => { 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), From c415f94184333fc7b262a35dff76252767af00f7 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:17:38 +0800 Subject: [PATCH 20/25] refactor: share goal time formatting --- .../tui/components/messages/goal-format.ts | 13 +++++++++++++ .../src/tui/components/messages/goal-panel.ts | 15 +++------------ .../messages/tool-renderers/goal.ts | 19 +++---------------- 3 files changed, 19 insertions(+), 28 deletions(-) create mode 100644 apps/kimi-code/src/tui/components/messages/goal-format.ts 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-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-renderers/goal.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts index f7717a3db..fd4286735 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -6,6 +6,7 @@ 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'; @@ -193,26 +194,12 @@ function parseGoalValue(output: string): Record | null | undefi function formatGoalStats(goal: GoalSnapshotView): string { return [ - pluralize(goal.turnsUsed, 'turn'), + pluralizeGoalCount(goal.turnsUsed, 'turn'), `${formatTokenCount(goal.tokensUsed)} tokens`, - formatElapsed(goal.wallClockMs), + formatGoalElapsed(goal.wallClockMs), ].join(' · '); } -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 pluralize(n: number, singular: string, plural?: string): string { - return `${String(n)} ${n === 1 ? singular : (plural ?? `${singular}s`)}`; -} - function truncateOneLine(text: string, max: number): string { const firstLine = text.replaceAll(/\s+/g, ' ').trim(); if (firstLine.length <= max) return firstLine; From ed0fa1811bcafe953f31527ef5fb05b1684a8fe5 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:10:13 +0800 Subject: [PATCH 21/25] fix: avoid duplicate blocked fallback --- .../messages/tool-renderers/goal.ts | 2 +- .../tui/controllers/session-event-handler.ts | 10 +++++++++- .../session-event-handler-goal-queue.test.ts | 20 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) 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 index fd4286735..bcf1e72bf 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -71,7 +71,7 @@ export function buildGoalToolHeader(options: { return `${marker}${label}${argText}${chip}`; } -export function formatGoalBudgetArg(args: Record): string | undefined { +function formatGoalBudgetArg(args: Record): string | undefined { const value = args['value']; const unit = args['unit']; if (typeof value !== 'number' || !Number.isFinite(value) || typeof unit !== 'string') { 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 d95161864..5b50382f0 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -136,6 +136,7 @@ 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; @@ -150,6 +151,7 @@ export class SessionEventHandler { this.mcpServers.clear(); this.goalCompletionAwaitingClear = false; this.goalCompletionTurnEnded = false; + this.currentTurnHasAssistantText = false; this.pendingModelBlockedFallback = undefined; this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; @@ -284,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); @@ -328,6 +331,7 @@ export class SessionEventHandler { this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeTurn(sendQueued); this.renderPendingModelBlockedFallback(); + this.currentTurnHasAssistantText = false; this.goalCompletionTurnEnded = true; this.scheduleQueuedGoalPromotion(); } @@ -420,6 +424,7 @@ export class SessionEventHandler { } if (event.delta.trim().length > 0) { + this.currentTurnHasAssistantText = true; this.pendingModelBlockedFallback = undefined; } streamingUI.appendAssistantDelta(event.delta); @@ -442,6 +447,7 @@ export class SessionEventHandler { } this.host.streamingUI.finalizeAssistantStream(); if (event.content.trim().length > 0) { + this.currentTurnHasAssistantText = true; this.pendingModelBlockedFallback = undefined; } this.host.appendTranscriptEntry({ @@ -613,7 +619,9 @@ export class SessionEventHandler { if (change.kind === 'lifecycle' && change.status === 'blocked') { void this.notifyQueuedGoalWaitingOnBlocked(); if (event.snapshot?.updatedBy === 'model') { - this.pendingModelBlockedFallback = change; + this.pendingModelBlockedFallback = this.currentTurnHasAssistantText + ? undefined + : change; return; } this.pendingModelBlockedFallback = undefined; 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 1634646a3..ab3c89572 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 @@ -412,6 +412,26 @@ describe('SessionEventHandler goal queue promotion', () => { 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); From a3fbc1abd05851409f195b4b23ea7c38b94079c0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 9 Jun 2026 10:33:49 +0800 Subject: [PATCH 22/25] refactor: move outcome.ts to usage --- packages/agent-core/src/agent/turn/index.ts | 3 ++- .../outcome.ts => tools/builtin/goal/outcome-prompts.ts} | 5 +---- packages/agent-core/src/tools/builtin/goal/update-goal.ts | 4 +++- packages/agent-core/test/agent/goal-outcome.test.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) rename packages/agent-core/src/{agent/goal/outcome.ts => tools/builtin/goal/outcome-prompts.ts} (92%) diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index a14c97fa1..094e3f5f4 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -37,7 +37,6 @@ import type { AgentEvent, TurnEndedEvent } from '../../rpc'; import type { TelemetryPropertyValue } from '../../telemetry'; import { abortable, userCancellationReason } from '../../utils/abort'; import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; -import { GOAL_BLOCKED_REMINDER_NAME, GOAL_COMPLETION_REMINDER_NAME } from '../goal/outcome'; import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks'; import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args'; import { ToolCallDeduplicator } from './tool-dedup'; @@ -69,6 +68,8 @@ 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'; diff --git a/packages/agent-core/src/agent/goal/outcome.ts b/packages/agent-core/src/tools/builtin/goal/outcome-prompts.ts similarity index 92% rename from packages/agent-core/src/agent/goal/outcome.ts rename to packages/agent-core/src/tools/builtin/goal/outcome-prompts.ts index 0583439df..d3279b0e9 100644 --- a/packages/agent-core/src/agent/goal/outcome.ts +++ b/packages/agent-core/src/tools/builtin/goal/outcome-prompts.ts @@ -1,7 +1,4 @@ -import type { GoalSnapshot } from './index'; - -export const GOAL_COMPLETION_REMINDER_NAME = 'goal_completion'; -export const GOAL_BLOCKED_REMINDER_NAME = 'goal_blocked'; +import type { GoalSnapshot } from '../../../agent/goal'; export function buildGoalCompletionSummaryPrompt(goal: GoalSnapshot): string { return [ 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 89684bb29..52110a3c0 100644 --- a/packages/agent-core/src/tools/builtin/goal/update-goal.ts +++ b/packages/agent-core/src/tools/builtin/goal/update-goal.ts @@ -16,9 +16,11 @@ import { z } from 'zod'; import { GOAL_BLOCKED_REMINDER_NAME, GOAL_COMPLETION_REMINDER_NAME, +} from '../../../agent/turn'; +import { buildGoalBlockedReasonPrompt, buildGoalCompletionSummaryPrompt, -} from '../../../agent/goal/outcome'; +} from './outcome-prompts'; import type { BuiltinTool } from '../../../agent/tool'; import type { ToolExecution } from '../../../loop/types'; import { toInputJsonSchema } from '../../support/input-schema'; diff --git a/packages/agent-core/test/agent/goal-outcome.test.ts b/packages/agent-core/test/agent/goal-outcome.test.ts index 78afcbd4f..01835c915 100644 --- a/packages/agent-core/test/agent/goal-outcome.test.ts +++ b/packages/agent-core/test/agent/goal-outcome.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { buildGoalBlockedReasonPrompt, buildGoalCompletionSummaryPrompt, -} from '../../src/agent/goal/outcome'; +} from '../../src/tools/builtin/goal/outcome-prompts'; import type { GoalSnapshot } from '../../src/agent/goal'; function snapshot(overrides: Partial = {}): GoalSnapshot { From 9b4411aef1d8f022ffbb4b72874e7ed362dca4dc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 9 Jun 2026 10:47:21 +0800 Subject: [PATCH 23/25] update --- packages/agent-core/src/agent/turn/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 094e3f5f4..e957eb018 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -625,7 +625,7 @@ export class TurnFlow { // final user-facing outcome message before the turn ends. if ( !goalOutcomeMessageContinuationUsed && - isGoalOutcomeReminder(this.agent.context.history.at(-1)) + isGoalOutcomeReminderOrigin(this.agent.context.history.at(-1)?.origin) ) { goalOutcomeMessageContinuationUsed = true; if (!hasStepBudgetRemaining(loopControl?.maxStepsPerTurn, ctx.stepNumber)) { @@ -869,10 +869,6 @@ export class TurnFlow { } } -function isGoalOutcomeReminder(message: { readonly origin?: PromptOrigin | undefined } | undefined): boolean { - return isGoalOutcomeReminderOrigin(message?.origin); -} - function isGoalOutcomeReminderOrigin(origin: PromptOrigin | undefined): boolean { return ( origin?.kind === 'system_trigger' && From 188143dfb449893146e21b672fe1aa66e29b70a8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 9 Jun 2026 11:19:59 +0800 Subject: [PATCH 24/25] fix --- .changeset/goal-completion-summary.md | 6 -- .changeset/goal-mode-outcomes.md | 6 ++ .changeset/goal-tool-display.md | 5 -- .changeset/pause-goals-on-model-errors.md | 6 -- .changeset/polish-goal-transcript-markers.md | 5 -- .changeset/show-blocked-goal-reasons.md | 6 -- .changeset/strengthen-goal-outcome-prompts.md | 6 -- .../src/tui/controllers/session-replay.ts | 47 +++++++++++++-- .../kimi-code/test/tui/message-replay.test.ts | 58 +++++++++++++++++++ packages/agent-core/src/agent/goal/index.ts | 3 + .../src/agent/records/migration/v1.4.ts | 3 + .../agent-core/src/agent/records/types.ts | 3 +- packages/agent-core/test/agent/goal.test.ts | 23 ++++++-- .../test/agent/records/index.test.ts | 3 +- .../test/agent/records/migration/v1.4.test.ts | 2 +- packages/agent-core/test/agent/resume.test.ts | 2 + 16 files changed, 138 insertions(+), 46 deletions(-) delete mode 100644 .changeset/goal-completion-summary.md create mode 100644 .changeset/goal-mode-outcomes.md delete mode 100644 .changeset/goal-tool-display.md delete mode 100644 .changeset/pause-goals-on-model-errors.md delete mode 100644 .changeset/polish-goal-transcript-markers.md delete mode 100644 .changeset/show-blocked-goal-reasons.md delete mode 100644 .changeset/strengthen-goal-outcome-prompts.md diff --git a/.changeset/goal-completion-summary.md b/.changeset/goal-completion-summary.md deleted file mode 100644 index 24f6a7282..000000000 --- a/.changeset/goal-completion-summary.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Ask the agent to summarize completed goals after it marks them complete. 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/.changeset/goal-tool-display.md b/.changeset/goal-tool-display.md deleted file mode 100644 index 2cf9e48fd..000000000 --- a/.changeset/goal-tool-display.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Show goal tool calls in the TUI with concise goal summaries instead of raw JSON payloads. diff --git a/.changeset/pause-goals-on-model-errors.md b/.changeset/pause-goals-on-model-errors.md deleted file mode 100644 index d4117be11..000000000 --- a/.changeset/pause-goals-on-model-errors.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Pause goals after model, provider, or runtime errors instead of blocking them. diff --git a/.changeset/polish-goal-transcript-markers.md b/.changeset/polish-goal-transcript-markers.md deleted file mode 100644 index 60e1ce1be..000000000 --- a/.changeset/polish-goal-transcript-markers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Polish goal transcript messages for blocked, cancelled, paused, and resumed goals. diff --git a/.changeset/show-blocked-goal-reasons.md b/.changeset/show-blocked-goal-reasons.md deleted file mode 100644 index 23ce3caf2..000000000 --- a/.changeset/show-blocked-goal-reasons.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Ask the agent to explain blocked goals before goal mode stops. diff --git a/.changeset/strengthen-goal-outcome-prompts.md b/.changeset/strengthen-goal-outcome-prompts.md deleted file mode 100644 index c13ba2ad4..000000000 --- a/.changeset/strengthen-goal-outcome-prompts.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/kimi-code": patch ---- - -Strengthen goal outcome prompts sent to the agent while preserving user-visible goal messages. diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 9435c987d..fd926c3cc 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -57,6 +57,9 @@ export interface SessionReplayHost { } export class SessionReplayRenderer { + private replayTurnHasAssistantText = false; + private pendingModelBlockedReplayFallback: GoalReplayLifecycleChange | undefined; + constructor(private readonly host: SessionReplayHost) {} async hydrateFromReplay(session: Session): Promise { @@ -166,10 +169,13 @@ export class SessionReplayRenderer { private renderRecords(agent: ResumedAgentState): void { const context = createReplayRenderContext(); + this.replayTurnHasAssistantText = false; + this.pendingModelBlockedReplayFallback = undefined; for (const record of limitReplayRecordsByTurn(agent.replay, REPLAY_TURN_LIMIT)) { this.renderRecord(context, record); } this.flushAssistant(context); + this.renderPendingModelBlockedReplayFallback(context); this.cleanupRuntime(context); } @@ -316,6 +322,8 @@ export class SessionReplayRenderer { } private advanceTurn(context: ReplayRenderContext): void { + this.renderPendingModelBlockedReplayFallback(context); + this.replayTurnHasAssistantText = false; context.turnIndex += 1; context.stepIndex = 0; context.currentTurnId = `replay:${String(context.turnIndex)}`; @@ -339,6 +347,8 @@ export class SessionReplayRenderer { streamingUI.onThinkingEnd(); } if (text.length > 0) { + this.replayTurnHasAssistantText = true; + this.pendingModelBlockedReplayFallback = undefined; streamingUI.onStreamingTextStart(); streamingUI.onStreamingTextUpdate(text); streamingUI.onStreamingTextEnd(); @@ -378,12 +388,14 @@ export class SessionReplayRenderer { const { change } = record; switch (change.kind) { case 'created': + this.renderPendingModelBlockedReplayFallback(context); this.host.appendTranscriptEntry({ ...replayEntry(context, 'goal', 'Goal set', 'plain'), goalData: { kind: 'created' }, }); return; case 'completion': + this.renderPendingModelBlockedReplayFallback(context); this.host.appendTranscriptEntry( replayEntry(context, 'assistant', buildGoalCompletionMessage(record.snapshot), 'markdown'), ); @@ -391,15 +403,38 @@ 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)) { + // Match the live path: wait for the assistant's blocker explanation, + // and only render the marker as a fallback if no text arrives. + this.pendingModelBlockedReplayFallback = this.replayTurnHasAssistantText + ? undefined + : lifecycleChange; + return; + } + this.renderPendingModelBlockedReplayFallback(context); + 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 renderPendingModelBlockedReplayFallback(context: ReplayRenderContext): void { + const change = this.pendingModelBlockedReplayFallback; + if (change === undefined) return; + this.pendingModelBlockedReplayFallback = undefined; + this.appendGoalLifecycleReplayEntry(context, change); + } + private renderHookResult(context: ReplayRenderContext, message: ContextMessage): void { if (message.origin?.kind !== 'hook_result') return; this.flushAssistant(context); @@ -620,6 +655,10 @@ function goalLifecycleReplayContent(change: GoalReplayLifecycleChange): string { } } +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') { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 8befbb5d5..c488de8d1 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -471,6 +471,64 @@ describe('KimiTUI resume message replay', () => { 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('renders a replayed model-blocked fallback when no follow-up is replayed', 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(['Goal blocked']); + }); + + 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('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/packages/agent-core/src/agent/goal/index.ts b/packages/agent-core/src/agent/goal/index.ts index 9f6f49d86..d8f8bafd3 100644 --- a/packages/agent-core/src/agent/goal/index.ts +++ b/packages/agent-core/src/agent/goal/index.ts @@ -300,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, }, }); } @@ -601,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/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": "