Skip to content
Open
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
12 changes: 12 additions & 0 deletions apps/kimi-code/src/tui/components/dialogs/approval-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,18 @@ export class ApprovalPanelComponent extends Container implements Focusable {
} else {
lines.push(indent(strong(` ${labelWithNum}`)));
}

// Optional helper text under the label, aligned past the pointer/number.
// Choices without a description render exactly as before.
if (
option.description !== undefined &&
option.description.length > 0 &&
!(this.feedbackMode && option.requires_feedback === true && isSelected)
) {
for (const descLine of wrapTextWithAnsi(option.description, Math.max(20, width - 7))) {
lines.push(indent(` ${dim(descLine)}`));
}
}
}

lines.push('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface GoalStartPermissionPromptOptions {
readonly onCancel: () => void;
}

const MANUAL_OPTIONS: readonly StartPermissionOption[] = [
export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [
{
value: 'auto',
label: 'Switch to Auto and start',
Expand All @@ -37,7 +37,7 @@ const MANUAL_OPTIONS: readonly StartPermissionOption[] = [
},
];

const YOLO_OPTIONS: readonly StartPermissionOption[] = [
export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [
{
value: 'auto',
label: 'Switch to Auto and start',
Expand All @@ -57,6 +57,14 @@ const YOLO_OPTIONS: readonly StartPermissionOption[] = [
},
];

export function goalStartOptions(mode: 'manual' | 'yolo'): readonly StartPermissionOption[] {
return mode === 'yolo' ? GOAL_START_YOLO_OPTIONS : GOAL_START_MANUAL_OPTIONS;
}

const MANUAL_OPTIONS = GOAL_START_MANUAL_OPTIONS;

const YOLO_OPTIONS = GOAL_START_YOLO_OPTIONS;

const MANUAL_NOTICE_LINES = [
'Manual mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.',
'Manual mode is not suitable for unattended goal work.',
Expand Down
7 changes: 6 additions & 1 deletion apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1477,7 +1477,12 @@ export class KimiTUI {
request: ApprovalRequest,
response: ApprovalResponse,
): void {
if (request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review') return;
if (
request.toolName === 'ExitPlanMode' ||
request.display.kind === 'plan_review' ||
request.display.kind === 'goal_start'
)
return;
const parts: string[] = [];
switch (response.decision) {
case 'approved':
Expand Down
36 changes: 36 additions & 0 deletions apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ApprovalRequest, ApprovalResponse, ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk';

import type { ApprovalPanelResponse } from '#/tui/components/dialogs/approval-panel';
import { goalStartOptions } from '#/tui/components/dialogs/goal-start-permission-prompt';
import type { ApprovalPanelChoice, ApprovalPanelData, DisplayBlock } from '#/tui/reverse-rpc/types';

const DEFAULT_APPROVAL_CHOICES: ApprovalPanelChoice[] = [
Expand Down Expand Up @@ -176,6 +177,8 @@ function describeApproval(display: ToolInputDisplay, action: string): string {
switch (display.kind) {
case 'plan_review':
return '';
case 'goal_start':
return 'Start a goal?';
case 'generic':
if (typeof display.detail === 'string' && display.detail.length > 0) {
return display.detail;
Expand Down Expand Up @@ -320,6 +323,13 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] {
];
case 'plan_review':
return [];
case 'goal_start': {
const lines = [`Start goal: ${display.objective}`];
if (typeof display.completionCriterion === 'string' && display.completionCriterion.length > 0) {
lines.push(`Done when: ${display.completionCriterion}`);
}
return [{ type: 'brief', text: lines.join('\n') }];
}
case 'generic':
return [];
case 'todo_list':
Expand All @@ -335,10 +345,36 @@ function adaptChoices(toolName: string, display: ToolInputDisplay): ApprovalPane
if (toolName === 'ExitPlanMode' || display.kind === 'plan_review') {
return adaptPlanReviewChoices(display);
}
if (display.kind === 'goal_start') {
return adaptGoalStartChoices(display);
}

return DEFAULT_APPROVAL_CHOICES.map((choice) => cloneChoice(choice));
}

function adaptGoalStartChoices(
display: Extract<ToolInputDisplay, { kind: 'goal_start' }>,
): ApprovalPanelChoice[] {
// Reuse the exact options the /goal start menu shows. Each mode option starts
// the goal under that permission mode (the policy reads selected_label); "Do
// not start" declines so no goal is created.
return goalStartOptions(display.mode).map((option) =>
option.value === 'cancel'
? {
label: option.label,
response: 'cancelled',
selected_label: 'cancel',
description: option.description,
}
: {
label: option.label,
response: 'approved',
selected_label: option.value,
description: option.description,
},
);
}

function adaptPlanReviewChoices(display: ToolInputDisplay): ApprovalPanelChoice[] {
const optionChoices =
display.kind === 'plan_review' && display.options !== undefined && display.options.length >= 2
Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-code/src/tui/reverse-rpc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ export interface ApprovalPanelChoice {
response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled';
selected_label?: string | undefined;
requires_feedback?: boolean | undefined;
// Optional helper text shown dim beneath the label. Omitted/empty renders
// exactly as a plain label-only choice.
description?: string | undefined;
}

// ── Approval / Question view payloads ────────────────────────────────
Expand Down
27 changes: 27 additions & 0 deletions apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,33 @@ describe('ApprovalPanelComponent', () => {
expect(out).not.toContain('y/a/n/f');
});

it('renders choice descriptions beneath the label when present', () => {
const pending: PendingApproval = {
data: {
id: 'approval_goal',
tool_call_id: 'tool_goal',
tool_name: 'CreateGoal',
action: 'Creating a goal',
description: '',
display: [],
choices: [
{
label: 'Switch to Auto and start',
response: 'approved',
selected_label: 'auto',
description: 'Tools are approved automatically, and questions are skipped.',
},
{ label: 'Do not start', response: 'cancelled', selected_label: 'cancel' },
],
},
};
const out = strip(new ApprovalPanelComponent(pending, () => {}).render(80).join('\n'));
expect(out).toContain('1. Switch to Auto and start');
expect(out).toContain('Tools are approved automatically, and questions are skipped.');
// A choice without a description stays label-only — no stray blank helper line.
expect(out).toContain('2. Do not start');
});

it('renders dangerous shell warnings with simple copy and no icon', () => {
const pending: PendingApproval = {
data: {
Expand Down
91 changes: 91 additions & 0 deletions apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,97 @@ describe('approval adapter', () => {
]);
});

it('renders the /goal start menu for a CreateGoal approval in manual mode', () => {
const adapted = adaptApprovalRequest({
toolCallId: 'tc-goal',
toolName: 'CreateGoal',
action: 'Creating a goal',
display: {
kind: 'goal_start',
objective: 'Fix the failing auth tests',
completionCriterion: 'npm test -- auth exits 0',
mode: 'manual',
},
});

// Objective + criterion are previewed as a brief block.
expect(adapted.display).toEqual([
{
type: 'brief',
text: 'Start goal: Fix the failing auth tests\nDone when: npm test -- auth exits 0',
},
]);
// Choices mirror the manual-mode /goal start menu; mode options approve and
// carry the mode in selected_label, "Do not start" cancels. Each keeps the
// /goal menu's description.
expect(adapted.choices).toEqual([
{
label: 'Switch to Auto and start',
response: 'approved',
selected_label: 'auto',
description:
'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.',
},
{
label: 'Switch to YOLO and start',
response: 'approved',
selected_label: 'yolo',
description:
'Tools and plan changes are approved automatically. Kimi Code may still ask you questions.',
},
{
label: 'Start in Manual',
response: 'approved',
selected_label: 'manual',
description:
'Keep approvals on. Kimi Code will ask before risky actions, so the goal may stop and wait for you.',
},
{
label: 'Do not start',
response: 'cancelled',
selected_label: 'cancel',
description: 'Return to the input box with your goal command.',
},
]);
});

it('renders the yolo-mode /goal start menu for a CreateGoal approval', () => {
const adapted = adaptApprovalRequest({
toolCallId: 'tc-goal-yolo',
toolName: 'CreateGoal',
action: 'Creating a goal',
display: {
kind: 'goal_start',
objective: 'Ship the feature',
mode: 'yolo',
},
});

expect(adapted.display).toEqual([{ type: 'brief', text: 'Start goal: Ship the feature' }]);
expect(adapted.choices).toEqual([
{
label: 'Switch to Auto and start',
response: 'approved',
selected_label: 'auto',
description:
'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.',
},
{
label: 'Keep YOLO and start',
response: 'approved',
selected_label: 'yolo',
description:
'Tools and plan changes stay approved automatically. Kimi Code may still ask you questions.',
},
{
label: 'Do not start',
response: 'cancelled',
selected_label: 'cancel',
description: 'Return to the input box with your goal command.',
},
]);
});

it('maps approved-for-session responses into core approval payloads', () => {
expect(
adaptPanelResponse({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Agent } from '../..';
import type {
ApprovalResponse,
PermissionMode,
PermissionPolicy,
PermissionPolicyContext,
PermissionPolicyResult,
} from '../types';

/**
* Starting a goal turns the agent loose on autonomous, multi-turn work, so a
* model-issued `CreateGoal` is confirmed with the same menu the `/goal` command
* shows: choose the permission mode to run the goal under, or decline. The
* chosen mode is applied before the goal is created so the run proceeds under
* it. `auto` mode auto-approves the goal upstream and never reaches here.
*/
export class GoalStartReviewAskPermissionPolicy implements PermissionPolicy {
readonly name = 'goal-start-review-ask';

constructor(private readonly agent: Agent) {}

evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined {
if (context.toolCall.name !== 'CreateGoal') return;
if (this.agent.permission.mode === 'auto') return;
if (context.execution.display?.kind !== 'goal_start') return;
return {
kind: 'ask',
resolveApproval: (result) => this.resolveGoalStart(result),
};
}

private resolveGoalStart(result: ApprovalResponse): undefined {
// Declining ("Do not start") or any non-approval creates no goal; the tool
// call is then blocked with the standard rejection message.
if (result.decision !== 'approved') return undefined;
// The selected option names the permission mode to run the goal under.
const mode = toPermissionMode(result.selectedLabel);
if (mode !== undefined && mode !== this.agent.permission.mode) {
this.agent.permission.setMode(mode);
}
// Approved: let CreateGoal execute and create the goal under the chosen mode.
return undefined;
}
}

function toPermissionMode(label: string | undefined): PermissionMode | undefined {
if (label === 'auto' || label === 'yolo' || label === 'manual') return label;
return undefined;
}
5 changes: 5 additions & 0 deletions packages/agent-core/src/agent/permission/policies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SensitiveFileAccessAskPermissionPolicy,
} from './file-access-ask';
import { GitCwdWriteApprovePermissionPolicy } from './git-cwd-write-approve';
import { GoalStartReviewAskPermissionPolicy } from './goal-start-review-ask';
import { PlanModeGuardDenyPermissionPolicy } from './plan-mode-guard-deny';
import { PlanModeToolApprovePermissionPolicy } from './plan-mode-tool-approve';
import { PreToolCallHookPermissionPolicy } from './pre-tool-call-hook';
Expand Down Expand Up @@ -46,6 +47,10 @@ export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy
new UserConfiguredAllowPermissionPolicy(agent),
// ExitPlanMode with active plan_review + non-empty plan + non-auto → ask (tracks plan_submitted/plan_resolved itself). Runs before session history so a stale session approval can't bypass review of a new plan body.
new ExitPlanModeReviewAskPermissionPolicy(agent),
// CreateGoal (non-auto) → ask with the same start menu as /goal: choose the
// permission mode to run the goal under, or decline. Applies the mode, then
// lets the tool create the goal.
new GoalStartReviewAskPermissionPolicy(agent),
// EnterPlanMode, Write/Edit on the plan file, or ExitPlanMode with no actionable plan_review → approve.
new PlanModeToolApprovePermissionPolicy(agent),
// Access touches a sensitive file (.env, SSH key, credentials) → ask.
Expand Down
12 changes: 7 additions & 5 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,12 +301,14 @@ export class TurnFlow {
return await this.driveGoal(firstTurnId, input, origin, signal);
}
const end = await this.runOneTurn(firstTurnId, input, origin, signal, true);
const resumedFromPausedOrBlocked =
initialGoalStatus === 'paused' || initialGoalStatus === 'blocked';
const currentGoalStatus = this.agent.goal.getGoal().goal?.status;
// A goal can become active during an ordinary turn: the model creates one
// with CreateGoal, or resumes a paused/blocked goal via UpdateGoal. Either
// way, hand the now-active goal to the driver so it is actually pursued,
// instead of stopping after the turn that merely started it. (The
// already-active case took the early return above.)
const goalBecameActive = this.agent.goal.getGoal().goal?.status === 'active';
if (
resumedFromPausedOrBlocked &&
currentGoalStatus === 'active' &&
goalBecameActive &&
Comment on lines +309 to +311

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep created-goal continuations cancelable

When CreateGoal makes a previously non-goal turn active, this broadened branch now enters driveGoal after runOneTurn(..., true) has already cleared activeTurn. The continuation loop then runs with hasActiveTurn === false, so user steering can launch a concurrent turn and cancel() cannot abort the original controller/signal for the autonomous goal run. This affects the new guided/model-authored goal path; keep the active turn alive (as the already-active goal path does) before starting these continuations.

Useful? React with 👍 / 👎.

end.event.reason !== 'cancelled' &&
end.event.reason !== 'failed'
) {
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core/src/skill/builtin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import {
SUB_SKILL_REVIEW,
} from './sub-skill';
import { UPDATE_CONFIG_SKILL } from './update-config';
import { WRITE_GOAL_SKILL } from './write-goal';

export function registerBuiltinSkills(registry: SessionSkillRegistry): void {
registry.registerBuiltinSkill(MCP_CONFIG_SKILL);
registry.registerBuiltinSkill(IMPORT_FROM_CC_CODEX_SKILL);
registry.registerBuiltinSkill(UPDATE_CONFIG_SKILL);
registry.registerBuiltinSkill(CUSTOM_THEME_SKILL);
registry.registerBuiltinSkill(WRITE_GOAL_SKILL);
registry.registerBuiltinSkill(SUB_SKILL_PARENT);
registry.registerBuiltinSkill(SUB_SKILL_REVIEW);
registry.registerBuiltinSkill(SUB_SKILL_CONSOLIDATE);
Expand All @@ -27,4 +29,5 @@ export {
SUB_SKILL_PARENT,
SUB_SKILL_REVIEW,
UPDATE_CONFIG_SKILL,
WRITE_GOAL_SKILL,
};
Loading
Loading