Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/goal-objective-length-warning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Warn in the footer while a typed `/goal` objective exceeds the 4000-character limit, and restore the input instead of losing it when an over-limit objective is rejected. The error message now suggests putting long content in a file and referencing the file path.
6 changes: 6 additions & 0 deletions .changeset/goal-objective-too-long-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/agent-core-v2": patch
---

Include the file-reference workaround in the `GOAL_OBJECTIVE_TOO_LONG` error message so clients surface how to submit long objectives.
57 changes: 54 additions & 3 deletions apps/kimi-code/src/tui/commands/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,13 @@ export type ParsedGoalCommand =
}
| { readonly kind: 'next-add'; readonly objective: string }
| { readonly kind: 'next-manage' }
| { readonly kind: 'error'; readonly message: string; readonly severity?: 'error' | 'hint' };
| {
readonly kind: 'error';
readonly message: string;
readonly severity?: 'error' | 'hint';
/** Restore the typed `/goal ...` line into the editor so the input is not lost. */
readonly restoreInput?: boolean;
};

const CONTROL_SUBCOMMANDS = new Set(['pause', 'resume', 'cancel']);

Expand Down Expand Up @@ -114,7 +120,8 @@ export function parseGoalCommand(rawArgs: string): ParsedGoalCommand {
if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) {
return {
kind: 'error',
message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Reference long details by file path.`,
restoreInput: true,
message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Put long content in a file and reference the file path.`,
};
}
return { kind: 'create', objective, replace };
Expand All @@ -126,6 +133,8 @@ export async function handleGoalCommand(host: SlashCommandHost, args: string): P
case 'error':
if (parsed.severity === 'hint') host.showStatus(parsed.message);
else host.showError(parsed.message);
// Give rejected input back so a long hand-typed objective is not lost.
if (parsed.restoreInput === true) host.restoreInputText(`/goal ${args}`);
Comment thread
chengluyu marked this conversation as resolved.
Outdated
Comment thread
chengluyu marked this conversation as resolved.
Outdated
return;
case 'status':
await showGoalStatus(host);
Expand Down Expand Up @@ -167,12 +176,54 @@ function parseNextGoalCommand(tokens: readonly string[]): ParsedGoalCommand {
if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) {
return {
kind: 'error',
message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Reference long details by file path.`,
restoreInput: true,
message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Put long content in a file and reference the file path.`,
};
}
return { kind: 'next-add', objective };
}

/**
* Live pre-send check for the main editor: when the typed text is a `/goal`
* create/next command whose objective already exceeds the length limit,
* returns a warning to show while typing — before anything is submitted or
* sent to the server. Returns undefined for non-goal input and for control
* forms (`status`/`pause`/`resume`/`cancel`/`next manage`).
*/
export function goalObjectiveLengthWarning(text: string): string | undefined {
if (!text.startsWith('/goal')) return undefined;
const args = text.slice('/goal'.length);
// `/goalfoo` is a different (probably unknown) command, not `/goal`.
if (args.length > 0 && !/\s/.test(args.charAt(0))) return undefined;
const objective = extractGoalObjective(args);
if (objective === undefined || objective.length <= MAX_GOAL_OBJECTIVE_LENGTH) return undefined;
return `Goal objective is too long (${objective.length}/${MAX_GOAL_OBJECTIVE_LENGTH} characters); put long content in a file and reference the file path.`;
}

/**
* Mirrors the parse grammar above: strips `next` / `replace` / `--` and
* returns the objective text, or undefined when the args form a control
* command that carries no objective.
*/
function extractGoalObjective(rawArgs: string): string | undefined {
const args = rawArgs.trim();
if (args.length === 0 || args === 'status') return undefined;
const tokens = args.split(/\s+/);
const first = tokens[0];
let index = 0;
if (first === 'next') {
if (tokens.length === 2 && tokens[1] === 'manage') return undefined;
index = 1;
} else {
if (first !== undefined && CONTROL_SUBCOMMANDS.has(first) && tokens.length === 1) {
return undefined;
}
if (tokens[index] === 'replace') index += 1;
}
if (tokens[index] === '--') index += 1;
return tokens.slice(index).join(' ').trim();
}

async function queueNextGoal(
host: SlashCommandHost,
parsed: Extract<ParsedGoalCommand, { kind: 'next-add' }>,
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export { handleSwarmCommand } from './swarm';
export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
export { handlePluginsCommand } from './plugins';
export { handleReloadCommand, handleReloadTuiCommand } from './reload';
export { handleGoalCommand, parseGoalCommand } from './goal';
export { handleGoalCommand, parseGoalCommand, goalObjectiveLengthWarning } from './goal';
export { goalArgumentCompletions } from './registry';
export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session';
export { handleUndoCommand } from './undo';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable
return;
}
if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) {
this.error = `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters.`;
this.error = `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters; put long content in a file and reference the file path.`;
return;
}
this.opts.onDone({ kind: 'save', goalId: this.opts.goal.id, objective });
Expand Down
11 changes: 11 additions & 0 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface EditorKeyboardHost {
showError(msg: string): void;
track(event: string, props?: Record<string, unknown>): void;
updateEditorBorderHighlight(text?: string): void;
/** `undefined` means the input cannot be a `/goal` command (clear without measuring). */
updateGoalLengthWarning(text: string | undefined): void;
updateQueueDisplay(): void;
toggleToolOutputExpansion(): void;
toggleTodoPanelExpansion(): void;
Expand Down Expand Up @@ -80,6 +82,15 @@ export class EditorKeyboardController {
editor.onChange = (text: string) => {
if (this.pendingExit) this.clearPendingExit();
host.updateEditorBorderHighlight(text);
// Expanding paste markers costs a full-text pass, and only `/goal`
// input can trip the objective length limit — so skip the expansion
// for ordinary prompts. A leading paste marker may itself expand
// into a `/goal ...` line, so it must pass the gate too.
if (editor.inputMode !== 'bash' && (text.startsWith('/goal') || text.startsWith('[paste #'))) {
Comment thread
chengluyu marked this conversation as resolved.
Outdated
host.updateGoalLengthWarning(editor.getExpandedText());
} else {
host.updateGoalLengthWarning(undefined);
}
};

// bash mode recalls only shell (`!`-prefixed) history entries; prompt mode
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/goal-queue-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ function normalizeObjective(value: string): string {
if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) {
throw new KimiError(
ErrorCodes.GOAL_OBJECTIVE_TOO_LONG,
`Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`,
`Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters. Put long content in a file and reference the file path.`,
);
}
return objective;
Expand Down
27 changes: 27 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
BUILTIN_SLASH_COMMANDS,
buildPluginSlashCommands,
buildSkillSlashCommands,
goalObjectiveLengthWarning,
isExperimentalFlagEnabled,
setExperimentalFeatures,
sortSlashCommands,
Expand Down Expand Up @@ -382,6 +383,8 @@ export class KimiTUI {

/** Timer that auto-clears the one-shot "moved to background" footer hint. */
private detachHintClearTimer: ReturnType<typeof setTimeout> | undefined;
/** Footer hint currently owned by the goal-objective length warning. */
private goalLengthWarningHint: string | undefined;

// The currently-mounted approval panel, if any. Kept so the full-screen
// preview viewer can restore focus to the exact same instance (and its
Expand Down Expand Up @@ -3121,6 +3124,30 @@ export class KimiTUI {
this.state.ui.requestRender();
}

/**
* Live pre-send warning in the footer while the typed `/goal` objective
* exceeds the length limit, so the user can trim it (or move it into a
* file) before submitting instead of losing the input to a rejection.
* `undefined` input means the text cannot be a `/goal` command and is not
* measured at all. Clears only the hint it set itself, leaving other
* transient hints (exit confirm, detach, image paste) untouched.
*/
updateGoalLengthWarning(text: string | undefined): void {
const warning = text === undefined ? undefined : goalObjectiveLengthWarning(text);
if (warning !== undefined) {
this.goalLengthWarningHint = warning;
this.state.footer.setTransientHint(warning);
Comment thread
chengluyu marked this conversation as resolved.
Outdated
this.state.ui.requestRender();
return;
}
const owned = this.goalLengthWarningHint;
this.goalLengthWarningHint = undefined;
if (owned !== undefined && this.state.footer.getTransientHint() === owned) {
this.state.footer.setTransientHint(null);
this.state.ui.requestRender();
}
}

async applyTheme(themeName: ThemeName, resolved?: ResolvedTheme): Promise<void> {
const palette = await getColorPalette(themeName === 'auto' ? (resolved ?? 'dark') : themeName);
currentTheme.setPalette(palette);
Expand Down
63 changes: 61 additions & 2 deletions apps/kimi-code/test/tui/commands/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
dispatchInput,
goalArgumentCompletions,
goalObjectiveLengthWarning,
handleGoalCommand,
parseGoalCommand,
setExperimentalFeatures,
Expand Down Expand Up @@ -213,8 +214,47 @@ describe('parseGoalCommand', () => {
});
});

it('rejects objectives longer than 4000 characters', () => {
expect(parseGoalCommand('x'.repeat(4001))).toMatchObject({ kind: 'error' });
it('rejects objectives longer than 4000 characters with a file-reference hint', () => {
expect(parseGoalCommand('x'.repeat(4001))).toEqual({
kind: 'error',
restoreInput: true,
message:
'Goal objective is too long (max 4000 characters). Put long content in a file and reference the file path.',
});
expect(parseGoalCommand(`next ${'x'.repeat(4001)}`)).toEqual({
kind: 'error',
restoreInput: true,
message:
'Goal objective is too long (max 4000 characters). Put long content in a file and reference the file path.',
});
});
});

describe('goalObjectiveLengthWarning', () => {
it('warns once the typed /goal objective exceeds the limit', () => {
const warning = goalObjectiveLengthWarning(`/goal ${'x'.repeat(4001)}`);
expect(warning).toContain('(4001/4000 characters)');
expect(warning).toContain('reference the file path');
});

it('warns for over-limit /goal next and /goal replace objectives', () => {
expect(goalObjectiveLengthWarning(`/goal next ${'x'.repeat(4001)}`)).toBeDefined();
expect(goalObjectiveLengthWarning(`/goal replace ${'x'.repeat(4001)}`)).toBeDefined();
expect(goalObjectiveLengthWarning(`/goal -- ${'x'.repeat(4001)}`)).toBeDefined();
});

it('stays quiet for valid objectives and non-goal input', () => {
expect(goalObjectiveLengthWarning(`/goal ${'x'.repeat(4000)}`)).toBeUndefined();
expect(goalObjectiveLengthWarning('/goal Ship feature X')).toBeUndefined();
expect(goalObjectiveLengthWarning('Ship feature X')).toBeUndefined();
});

it('stays quiet for control forms and lookalike commands', () => {
expect(goalObjectiveLengthWarning('/goal')).toBeUndefined();
expect(goalObjectiveLengthWarning('/goal status')).toBeUndefined();
expect(goalObjectiveLengthWarning('/goal pause')).toBeUndefined();
expect(goalObjectiveLengthWarning('/goal next manage')).toBeUndefined();
expect(goalObjectiveLengthWarning(`/goalie ${'x'.repeat(4001)}`)).toBeUndefined();
});
});

Expand Down Expand Up @@ -266,6 +306,25 @@ describe('handleGoalCommand', () => {
expect(calls).toEqual([{ receiver: host, text: 'Ship feature X' }]);
});

it('rejects an over-limit objective before sending and restores the typed input', async () => {
const args = 'x'.repeat(4001);
await handleGoalCommand(host, args);

expect(session.createGoal).not.toHaveBeenCalled();
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
expect(host.showError).toHaveBeenCalledWith(
'Goal objective is too long (max 4000 characters). Put long content in a file and reference the file path.',
);
expect(host.restoreInputText).toHaveBeenCalledWith(`/goal ${args}`);
});

it('does not restore input for the empty-objective usage hint', async () => {
await handleGoalCommand(host, 'replace');

expect(host.showStatus).toHaveBeenCalled();
expect(host.restoreInputText).not.toHaveBeenCalled();
});

it('asks before starting a goal in Manual mode', async () => {
const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' });

Expand Down
72 changes: 72 additions & 0 deletions apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ function createHarness(options: { streamingPhase?: string; isCompacting?: boolea
btwPanelController: { cancelRunning: btwCancelRunning, closeOrCancel: btwCloseOrCancel },
openUndoSelector,
cancelRunningShellCommand,
updateEditorBorderHighlight: vi.fn(),
updateGoalLengthWarning: vi.fn(),
} as unknown as EditorKeyboardHost;

const controller = new EditorKeyboardController(
Expand Down Expand Up @@ -286,6 +288,76 @@ describe('EditorKeyboardController shell history recall', () => {
});
});

describe('EditorKeyboardController input changes', () => {
function installExpandedText(
editor: Harness['editor'],
expanded: string,
): ReturnType<typeof vi.fn> {
const getExpandedText = vi.fn(() => expanded);
editor['getExpandedText'] = getExpandedText as unknown as (...args: never[]) => unknown;
return getExpandedText;
}

it('forwards text changes to the border highlight and goal length warning', () => {
const { host, editor } = createHarness();
installExpandedText(editor, '/goal Ship feature X');
const onChange = editor['onChange'] as unknown as (text: string) => void;

onChange('/goal Ship feature X');

expect(host.updateEditorBorderHighlight).toHaveBeenCalledWith('/goal Ship feature X');
expect(host.updateGoalLengthWarning).toHaveBeenCalledWith('/goal Ship feature X');
});

it('measures the goal length warning on paste-expanded text, not the collapsed marker', () => {
const { host, editor } = createHarness();
const expanded = `/goal ${'x'.repeat(4001)}`;
installExpandedText(editor, expanded);
const onChange = editor['onChange'] as unknown as (text: string) => void;

// The visible text only holds the collapsed paste marker.
onChange('/goal [paste #1 +4000 chars]');

expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(expanded);
});

it('expands a leading paste marker because its content may start with /goal', () => {
const { host, editor } = createHarness();
const expanded = `/goal ${'x'.repeat(4001)}`;
const getExpandedText = installExpandedText(editor, expanded);
const onChange = editor['onChange'] as unknown as (text: string) => void;

onChange('[paste #1 +4000 chars]');

expect(getExpandedText).toHaveBeenCalled();
expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(expanded);
});

it('skips paste expansion entirely for non-goal input', () => {
const { host, editor } = createHarness();
const getExpandedText = installExpandedText(editor, 'whatever');
const onChange = editor['onChange'] as unknown as (text: string) => void;

onChange('just a normal prompt');
onChange('/help');

expect(getExpandedText).not.toHaveBeenCalled();
expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(undefined);
});

it('skips the goal length warning in bash mode', () => {
const { host, editor } = createHarness();
const getExpandedText = installExpandedText(editor, '/goal x');
(editor as unknown as { inputMode: string }).inputMode = 'bash';
const onChange = editor['onChange'] as unknown as (text: string) => void;

onChange('/goal x');

expect(getExpandedText).not.toHaveBeenCalled();
expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(undefined);
});
});

describe('EditorKeyboardController Shift-Tab plan toggle', () => {
function createShiftTabHarness(options: { sessionless?: boolean; engineV2?: boolean } = {}) {
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/agent/goal/goalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) {
throw new Error2(
ErrorCodes.GOAL_OBJECTIVE_TOO_LONG,
`Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`,
`Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters. Put long content in a file and reference the file path.`,
);
}
return objective;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/agent/goal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ export class GoalMode {
if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) {
throw new KimiError(
ErrorCodes.GOAL_OBJECTIVE_TOO_LONG,
`Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`,
`Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters. Put long content in a file and reference the file path.`,
);
}

Expand Down
Loading