Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ea1293c
feat: cap WaitFor at 90s and let new input end the wait
chengluyu Sep 28, 2026
2289e1e
docs: document the 90s WaitFor cap and Enter ending a wait
chengluyu Sep 28, 2026
767f98a
fix: requeue input when steering it into a WaitFor fails
chengluyu Sep 28, 2026
5ceb8c5
fix: render WaitFor results interrupted by new input
chengluyu Sep 28, 2026
f84192e
fix: keep staged media when a WaitFor steer fails
chengluyu Sep 28, 2026
750501c
docs: note when queued messages are not steered into a wait
chengluyu Sep 28, 2026
b0fded2
fix: keep submission order when a WaitFor steer fails
chengluyu Sep 28, 2026
65b25e6
fix: drop or dispatch failed WaitFor steers after a session or turn c…
chengluyu Sep 28, 2026
1f40d5e
fix: warn on every repeated WaitFor result
chengluyu Sep 28, 2026
517ba3b
fix: satisfy lint in transcript entry removal
chengluyu Sep 28, 2026
242e5a7
fix: keep staged media until an in-flight dispatch settles
chengluyu Sep 28, 2026
3f68738
fix: steer only into a running wait and hold the queue while the stee…
chengluyu Sep 28, 2026
b37c1ee
fix: snapshot the steered queue batch
chengluyu Sep 28, 2026
4840cc0
fix: ignore Ctrl-S while an automatic queue steer is in flight
chengluyu Sep 28, 2026
f20d480
fix: report WaitFor progress only once a wait has lasted an interval
chengluyu Sep 28, 2026
31a5e68
fix: drain after any settled queue steer and hide Ctrl-S while it is …
chengluyu Sep 28, 2026
7d19195
fix: refresh expiring image uploads before steering the queue
chengluyu Sep 28, 2026
1c4783f
feat: make the repeat-WaitFor warning firmer
chengluyu Sep 29, 2026
925f669
chore: release the WaitFor change as a minor
chengluyu Sep 29, 2026
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/shorter-wait-for.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Cap the agent's in-turn wait for background tasks at 90 seconds (longer timeouts are no longer accepted), discourage repeated waits, and let a new message end the wait immediately.
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type { ResultRenderer } from './types';
const DESCRIPTION_MAX = 72;
const RUNNING_SAMPLES = 3;

type WaitForStatus = 'completed' | 'timed_out' | 'no_tasks';
type WaitForStatus = 'completed' | 'timed_out' | 'interrupted' | 'no_tasks';

interface WaitForResultView {
readonly status: WaitForStatus;
Expand Down Expand Up @@ -77,6 +77,9 @@ export function buildWaitForHeader(options: {
if (status === 'no_tasks') {
return `${bullet}${currentTheme.boldFg('primary', 'No background tasks running')}${chip}`;
}
if (status === 'interrupted') {
return `${bullet}${currentTheme.boldFg('primary', 'Wait interrupted by new input')}${argText}${chip}`;
}
const label = taskId === undefined ? 'Waited for a background task' : 'Waited for background task';
return `${bullet}${currentTheme.boldFg('primary', label)}${argText}${chip}`;
}
Expand All @@ -92,7 +95,8 @@ function glanceLines(view: WaitForResultView): string[] {
switch (view.status) {
case 'no_tasks':
return [];
case 'timed_out': {
case 'timed_out':
case 'interrupted': {
if (view.runningCount === 0) return [];
const summary = `${pluralizeTasks(view.runningCount)} still running`;
if (view.runningSamples.length === 0) return [summary];
Expand Down Expand Up @@ -124,7 +128,14 @@ function pluralizeTasks(count: number): string {

export function parseWaitForOutput(output: string): WaitForResultView | undefined {
const status = field(output, 'wait_status');
if (status !== 'completed' && status !== 'timed_out' && status !== 'no_tasks') return undefined;
if (
status !== 'completed' &&
status !== 'timed_out' &&
status !== 'interrupted' &&
status !== 'no_tasks'
) {
return undefined;
}
const waitedMs = Number(field(output, 'waited_ms') ?? 0);
const finished = section(output, 'finished');
const duringWait = section(output, 'completed_during_wait');
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export interface EditorKeyboardHost {
}): boolean;
releaseStagingMedia(mediaAttachmentIds: readonly number[]): void;
recallLastQueued(): QueuedMessage | undefined;
isSteeringQueuedMessages(): boolean;
showError(msg: string): void;
track(event: string, props?: Record<string, unknown>): void;
updateEditorBorderHighlight(text?: string): void;
Expand Down Expand Up @@ -321,6 +322,10 @@ export class EditorKeyboardController {
host.state.appState.isCompacting
)
return;
// An automatic steer of the queue is still in flight: steering more now
// could reach the model ahead of it, so the keypress is ignored for
// that brief window and the draft stays in the editor.
if (host.isSteeringQueuedMessages()) return;
Comment thread
chengluyu marked this conversation as resolved.
const text = editor.getText().trim();
const editorIsBash = editor.inputMode === 'bash';

Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export interface SessionEventHost {
updateTerminalTitle(): void;
sendQueuedMessage(session: Session, item: QueuedMessage): void;
shiftQueuedMessage(): QueuedMessage | undefined;
steerQueuedMessagesIntoRunningTurn(): void;
handleTurnStarted?(event: TurnStartedEvent): void;
handleTurnEnded?(event: TurnEndedEvent): void;
readonly btwPanelController: BtwPanelController;
Expand Down Expand Up @@ -670,6 +671,11 @@ export class SessionEventHandler {
}

private handleToolProgress(event: ToolProgressEvent): void {
// Input queued before the wait began would otherwise sit until the wait
// returns; steering it now ends the wait so the model reads it first.
if (this.host.streamingUI.markWaitForRunning(event.toolCallId)) {
this.host.steerQueuedMessagesIntoRunningTurn();
Comment thread
chengluyu marked this conversation as resolved.
Comment on lines +676 to +677

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Mark short blocking waits before their first progress tick

If a genuinely blocking WaitFor finishes in under one second, it emits no progress update because startWaitProgress schedules its first tick after PROGRESS_INTERVAL_MS; therefore this is never called and Enter input submitted during that wait remains queued until the whole turn ends rather than interrupting the wait as documented. The fresh counterexample to the already-finished-task fix is a nonterminal task that is still pending when Enter is pressed but completes before the first tick; use an execution-start signal that distinguishes this case from an already-terminal task instead of relying solely on periodic progress.

AGENTS.md reference: AGENTS.md:L98-L100

Useful? React with 👍 / 👎.

}
const text = event.update.text;
if (text === undefined || text.length === 0) return;
const tc = this.host.streamingUI.getToolComponent(event.toolCallId);
Expand Down
42 changes: 34 additions & 8 deletions apps/kimi-code/src/tui/controllers/staging-leases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export class StagingLeaseTracker {
private readonly leasesByTurn = new Map<string, Set<StagingLease>>();
/** Leases carrying a client-chosen submission id, for exact `promptId` binding. */
private readonly leasesBySubmissionId = new Map<string, StagingLease>();
/** Leases whose dispatch RPC has not settled yet (see {@link trackDispatch}). */
private readonly inFlight = new Set<StagingLease>();
/** In-flight leases whose turn already ended; released once the RPC settles. */
private readonly endedInFlight = new Set<StagingLease>();
/**
* Cache copies whose consuming turn already ended. Persisted history may
* still reference their paths (skill/plugin args carry them as plain
Expand Down Expand Up @@ -167,31 +171,53 @@ export class StagingLeaseTracker {
const turnId = String(event.turnId);
const leases = this.leasesByTurn.get(turnId);
if (leases === undefined) return;
for (const lease of leases) this.releaseConsumed(lease);
this.leasesByTurn.delete(turnId);
for (const lease of leases) {
if (this.inFlight.has(lease)) {
this.endedInFlight.add(lease);
continue;
}
this.releaseConsumed(lease);
}
if (this.leasesByTurn.get(turnId)?.size === 0) this.leasesByTurn.delete(turnId);
}

/**
* Track a dispatch RPC carrying staged media. When it rejects, run
* `onError` and release the lease — but only while no turn has claimed it:
* a bound lease is owned by the turn and released at turn end, whatever the
* RPC's later outcome.
* a bound lease is owned by the turn and released at turn end. A turn that
* ends while the RPC is still in flight leaves the lease alone until the RPC
* settles: success releases it as consumed, failure as never consumed —
* unless `onError` handed it back to raw ownership via {@link defer}, as a
* caller that requeues the failed input does.
*/
trackDispatch(
lease: StagingLease | undefined,
request: Promise<unknown>,
onError: (error: unknown) => void,
): void {
if (lease !== undefined) this.inFlight.add(lease);
this.track(
request
.catch((error: unknown) => {
request.then(
() => {
this.settleInFlight(lease, true);
},
(error: unknown) => {
onError(error);
if (lease?.turnId === undefined) this.release(lease);
})
.then(() => undefined),
this.settleInFlight(lease, false);
},
),
);
}

private settleInFlight(lease: StagingLease | undefined, consumed: boolean): void {
if (lease === undefined) return;
this.inFlight.delete(lease);
if (!this.endedInFlight.delete(lease)) return;
if (consumed) this.releaseConsumed(lease);
else this.release(lease);
}

/**
* Release staged media that will never be consumed (dispatch failed before
* a turn claimed the lease): delete daemon uploads and cache copies now.
Expand Down
18 changes: 18 additions & 0 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export class StreamingUIController {
private _activeThinkingComponent: ThinkingComponent | undefined = undefined;
private _activeCompactionBlock: CompactionComponent | undefined = undefined;
private _activeToolCalls = new Map<string, ToolCallBlockData>();
private _runningWaitForCalls = new Set<string>();
private _streamingToolCallArguments = new Map<
string,
{ name?: string; argumentsText: string; startedAtMs: number }
Expand Down Expand Up @@ -147,6 +148,21 @@ export class StreamingUIController {
return this._activeToolCalls.has(id);
}

/** Marks a main-agent WaitFor call as actually waiting — its first
* progress update arrives only once the wait began, never for calls that
* were rejected, skipped, or had nothing to wait for. Returns whether the
* call was newly marked. */
markWaitForRunning(toolCallId: string): boolean {
if (this._activeToolCalls.get(toolCallId)?.name !== 'WaitFor') return false;
if (this._runningWaitForCalls.has(toolCallId)) return false;
this._runningWaitForCalls.add(toolCallId);
return true;
}

isWaitForRunning(): boolean {
return this._runningWaitForCalls.size > 0;
}

setActiveToolCall(id: string, toolCall: ToolCallBlockData): void {
this._activeToolCalls.set(id, toolCall);
}
Expand Down Expand Up @@ -358,6 +374,7 @@ export class StreamingUIController {
this.onToolCallEnd(toolCallId, result);
}
this._activeToolCalls.delete(toolCallId);
this._runningWaitForCalls.delete(toolCallId);
this._streamingToolCallArguments.delete(toolCallId);
return matchedCall;
}
Expand Down Expand Up @@ -542,6 +559,7 @@ export class StreamingUIController {

resetToolCallState(): void {
this._activeToolCalls.clear();
this._runningWaitForCalls.clear();
}

finalizeLiveTextBuffers(nextMode: LivePaneState['mode'] = 'idle'): void {
Expand Down
Loading
Loading