Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-goal-resume-agent-records.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code-sdk": patch
"@moonshot-ai/kimi-code": patch
---

Fix goal resume behavior by restoring goal state from agent records.
1 change: 1 addition & 0 deletions apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ async function runHeadlessGoal(
const unsubscribeGoalEvents = session.onEvent((event) => {
if (
event.type === 'goal.updated' &&
event.agentId === 'main' &&
event.change?.kind === 'completion' &&
event.snapshot !== null
) {
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/undo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ function isUndoContextEntry(entry: TranscriptEntry): boolean {
case 'cron':
return true;
case 'status':
case 'goal':
return entry.turnId !== undefined;
case 'welcome':
return false;
Expand Down
5 changes: 4 additions & 1 deletion apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,9 +419,12 @@ function goalSnapshotKey(goal: AppState['goal']): string | null {
return [
goal.goalId,
goal.status,
goal.terminalReason ?? '',
String(goal.turnsUsed),
String(goal.tokensUsed),
String(goal.wallClockMs),
goal.updatedAt,
String(goal.budget.tokenBudget),
String(goal.budget.turnBudget),
String(goal.budget.wallClockBudgetMs),
].join('\u0000');
}
6 changes: 3 additions & 3 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import type {
TurnStepStartedEvent,
WarningEvent,
} from '@moonshot-ai/kimi-code-sdk';
import { buildGoalCompletionMessage } from '@moonshot-ai/kimi-code-sdk';

import { MoonLoader } from '../components/chrome/moon-loader';
import { buildGoalMarker } from '../components/messages/goal-markers';
Expand All @@ -42,6 +41,7 @@ import {
OAUTH_LOGIN_REQUIRED_CODE,
OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE,
} from '../constant/kimi-tui';
import { buildGoalCompletionMessage } from '../utils/goal-completion';
import {
argsRecord,
formatErrorPayload,
Expand Down Expand Up @@ -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.
// Resume renders the same text from the durable goal completion replay
// record, so live and replayed completion cards stay identical.
if (change.kind === 'completion' && event.snapshot !== null) {
this.goalCompletionAwaitingClear = true;
this.goalCompletionTurnEnded = false;
Expand Down
83 changes: 71 additions & 12 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AgentReplayRecord,
ContextMessage,
GoalChange,
PermissionMode,
PromptOrigin,
ResumedAgentState,
Expand All @@ -19,6 +20,7 @@ import type {
import { formatErrorMessage, isTodoItemShape } from '../utils/event-payload';
import { formatBackgroundAgentTranscript } from '../utils/background-agent-status';
import { formatBackgroundTaskTranscript } from '../utils/background-task-status';
import { buildGoalCompletionMessage } from '../utils/goal-completion';
import {
appStateFromResumeAgent,
backgroundOrigin,
Expand All @@ -42,6 +44,9 @@ import type { StreamingUIController } from './streaming-ui';
import type { SessionEventHandler } from './session-event-handler';
import type { TUIState } from '../tui-state';

type GoalReplayRecord = Extract<AgentReplayRecord, { type: 'goal_updated' }>;
type GoalReplayLifecycleChange = GoalChange & { readonly kind: 'lifecycle' };

export interface SessionReplayHost {
state: TUIState;
readonly streamingUI: StreamingUIController;
Expand Down Expand Up @@ -173,6 +178,9 @@ export class SessionReplayRenderer {
case 'message':
this.renderMessage(context, record.message);
return;
case 'goal_updated':
this.renderGoalReplayRecord(context, record);
return;
case 'plan_updated':
this.flushAssistant(context);
if (!record.enabled && context.suppressNextPlanModeOffNotice) {
Expand Down Expand Up @@ -245,12 +253,10 @@ 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'),
);
if (isGoalForkClearedSystemReminder(message)) {
return;
}
if (isGoalCompletionSystemReminder(message)) {
return;
}

Expand Down Expand Up @@ -360,6 +366,33 @@ export class SessionReplayRenderer {
});
}

private renderGoalReplayRecord(context: ReplayRenderContext, record: GoalReplayRecord): void {
this.flushAssistant(context);
const { change } = record;
switch (change.kind) {
case 'created':
this.host.appendTranscriptEntry({
...replayEntry(context, 'goal', 'Goal set', 'plain'),
goalData: { kind: 'created' },
});
return;
case 'completion':
this.host.appendTranscriptEntry(
replayEntry(context, 'assistant', buildGoalCompletionMessage(record.snapshot), 'markdown'),
);
return;
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 },
});
return;
}
}
}

private renderHookResult(context: ReplayRenderContext, message: ContextMessage): void {
if (message.origin?.kind !== 'hook_result') return;
this.flushAssistant(context);
Expand Down Expand Up @@ -553,13 +586,39 @@ export class SessionReplayRenderer {
}
}

function goalCompletionFromSystemReminder(message: ContextMessage): string | null {
if (message.origin?.kind !== 'system_trigger' || message.origin.name !== 'goal_completion') {
return null;
const RESUME_NORMALIZATION_GOAL_PAUSE_REASONS = new Set([
'Paused after agent resume',
'Paused after session resume',
]);

function isResumeNormalizationGoalPause(change: GoalReplayLifecycleChange): boolean {
return (
change.status === 'paused' &&
change.reason !== undefined &&
RESUME_NORMALIZATION_GOAL_PAUSE_REASONS.has(change.reason)
);
}

function goalLifecycleReplayContent(change: GoalReplayLifecycleChange): string {
switch (change.status) {
case 'paused':
return 'Goal paused';
case 'active':
return 'Goal resumed';
case 'blocked':
return 'Goal blocked';
case 'complete':
case undefined:
return 'Goal updated';
}
const text = contentPartsToText(message.content);
const match = /^<system-reminder>\n([\s\S]*)\n<\/system-reminder>$/.exec(text);
return match?.[1] ?? text;
}

function isGoalCompletionSystemReminder(message: ContextMessage): boolean {
return message.origin?.kind === 'system_trigger' && message.origin.name === 'goal_completion';
}

function isGoalForkClearedSystemReminder(message: ContextMessage): boolean {
return message.origin?.kind === 'system_trigger' && message.origin.name === 'goal_fork_cleared';
}

function extractCronPrompt(text: string): string {
Expand Down
18 changes: 17 additions & 1 deletion apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ import { FileMentionProvider } from './components/editor/file-mention-provider';
import { AssistantMessageComponent } from './components/messages/assistant-message';
import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status';
import { CronMessageComponent } from './components/messages/cron-message';
import { GoalCompletionMessageComponent } from './components/messages/goal-panel';
import { buildGoalMarker } from './components/messages/goal-markers';
import {
GoalCompletionMessageComponent,
GoalSetMessageComponent,
} from './components/messages/goal-panel';
import { SkillActivationComponent } from './components/messages/skill-activation';
import {
NoticeMessageComponent,
Expand Down Expand Up @@ -1299,6 +1303,18 @@ export class KimiTUI {
entry.cronData ?? {},
this.state.theme.colors,
);
case 'goal':
if (entry.goalData?.kind === 'created') {
return new GoalSetMessageComponent(this.state.theme.colors);
}
if (entry.goalData?.kind === 'lifecycle') {
return buildGoalMarker(
entry.goalData.change,
this.state.theme.colors,
this.state.toolOutputExpanded,
);
}
return null;
case 'assistant': {
if (entry.content.trimStart().startsWith('✓ Goal complete')) {
return new GoalCompletionMessageComponent(entry.content, this.state.theme.colors);
Expand Down
9 changes: 8 additions & 1 deletion apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
GoalChange,
GoalSnapshot,
ModelAlias,
PermissionMode,
Expand Down Expand Up @@ -110,6 +111,10 @@ export interface CronTranscriptData {
readonly missedCount?: number;
}

export type GoalTranscriptData =
| { readonly kind: 'created' }
| { readonly kind: 'lifecycle'; readonly change: GoalChange };

export type TranscriptEntryKind =
| 'welcome'
| 'user'
Expand All @@ -118,7 +123,8 @@ export type TranscriptEntryKind =
| 'thinking'
| 'status'
| 'skill_activation'
| 'cron';
| 'cron'
| 'goal';

export type SkillActivationTrigger = 'user-slash' | 'model-tool' | 'nested-skill';

Expand All @@ -134,6 +140,7 @@ export interface TranscriptEntry {
backgroundAgentStatus?: BackgroundAgentStatusData;
compactionData?: CompactionTranscriptData;
cronData?: CronTranscriptData;
goalData?: GoalTranscriptData;
imageAttachmentIds?: readonly number[];
skillActivationId?: string;
skillName?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import type { GoalSnapshot } from '../../session/goal';
import type { GoalSnapshot } from '@moonshot-ai/kimi-code-sdk';

/**
* The deterministic goal-completion message. When the model marks a goal
* `complete` via UpdateGoal, the tool stores this verbatim inside a
* `<system-reminder>` (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
* guaranteed exact.
* Deterministic goal-completion text rendered by the TUI when the model marks a
* goal `complete`. It is built from the final snapshot, so the figures
* (turns / tokens / time) are exact and do not depend on model prose.
*/
export function buildGoalCompletionMessage(goal: GoalSnapshot): string {
const head = `✓ Goal complete${goal.terminalReason ? ` — ${goal.terminalReason}` : ''}.`;
Expand Down
4 changes: 0 additions & 4 deletions apps/kimi-code/test/cli/goal-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ function snapshot(overrides: Record<string, unknown> = {}) {
goalId: 'g1',
objective: 'work',
status: 'complete',
createdAt: '',
updatedAt: '',
startedBy: 'user',
updatedBy: 'model',
turnsUsed: 2,
tokensUsed: 120,
wallClockMs: 0,
Expand Down
4 changes: 0 additions & 4 deletions apps/kimi-code/test/tui/commands/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,6 @@ function fakeSnapshot() {
goalId: 'g1',
objective: 'obj',
status: 'active' as const,
createdAt: '',
updatedAt: '',
startedBy: 'user' as const,
updatedBy: 'user' as const,
turnsUsed: 0,
tokensUsed: 0,
wallClockMs: 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@ function fakeGoalSnapshot(objective: string, status: 'active' | 'blocked' | 'pau
goalId: 'g1',
objective,
status,
createdAt: '',
updatedAt: '',
startedBy: 'user' as const,
updatedBy: status === 'complete' || status === 'blocked' ? 'model' as const : 'user' as const,
turnsUsed: 1,
tokensUsed: 10,
wallClockMs: 100,
Expand Down
4 changes: 0 additions & 4 deletions apps/kimi-code/test/tui/kimi-tui-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,6 @@ function goalSnapshot(overrides: Partial<GoalSnapshot> = {}): GoalSnapshot {
goalId: "goal-1",
objective: "Ship feature X",
status: "paused",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
startedBy: "user",
updatedBy: "user",
turnsUsed: 2,
tokensUsed: 100,
wallClockMs: 1000,
Expand Down
Loading
Loading