Skip to content

Commit e6ea26d

Browse files
fix(server): Stop right after sending a Kiro message cancels the turn
Kiro CLI 2.27 drops a `session/cancel` that arrives before it has started the prompt (live: 0-0.5 s after `session/prompt`) and runs the turn to completion. A new shared ACP flavor option, `cancelAfterPromptStarts`, holds the cancel until the agent's first prompt-scoped `session/update`, and skips it when the prompt settles first. Kiro turns it on. This mirrors CodexAdapterV2 waiting for `turn/started` before `turn/interrupt`. The ACP replay agent honours `afterMs` on inbound frames, so tests can hold Kiro's first update until after Stop. Reported by @TinBane on Kiro CLI 2.27.1. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 778c075 commit e6ea26d

4 files changed

Lines changed: 135 additions & 3 deletions

File tree

‎apps/server/scripts/acp-replay-agent.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
#!/usr/bin/env node
2-
// @effect-diagnostics nodeBuiltinImport:off
2+
// @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- A plain Node child; `afterMs` delays an inbound frame.
33
import * as NodeFS from "node:fs";
44
import * as NodeReadline from "node:readline";
55

66
interface ReplayEntry {
77
readonly type: "emit_inbound" | "expect_outbound" | "runtime_exit";
88
readonly label?: string;
99
readonly frame?: unknown;
10+
/** Delay before an `emit_inbound` frame; an outbound frame arriving meanwhile is a mismatch. */
11+
readonly afterMs?: number;
1012
readonly status?: "success" | "error" | "cancelled";
1113
readonly error?: unknown;
1214
}
@@ -72,6 +74,7 @@ function resumedCursor(): number {
7274
}
7375
let cursor = resumedCursor();
7476
let stopped = false;
77+
let delayedCursor: number | undefined;
7578
let nextAgentRequestId = 1;
7679
const pendingClientRequestIds = new Map<string, string | number>();
7780
const pendingAgentRequestMethods = new Map<string, string>();
@@ -258,6 +261,11 @@ function flushInbound(): void {
258261
if (cursor < transcript.entries.length) return;
259262
continue;
260263
}
264+
if (entry.afterMs !== undefined && entry.afterMs > 0 && delayedCursor !== cursor) {
265+
delayedCursor = cursor;
266+
setTimeout(flushInbound, entry.afterMs);
267+
return;
268+
}
261269
const frame = entry.frame as LogicalFrame;
262270
if (
263271
typeof frame !== "object" ||

‎apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,13 @@ export interface AcpAdapterV2Flavor {
403403
readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;
404404
/** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */
405405
readonly interruptPromptOnCancel?: boolean;
406+
/**
407+
* Hold `session/cancel` until the agent sends its first prompt-scoped
408+
* `session/update`, or skip it when the prompt settles first. Kiro 2.27
409+
* drops a cancel that arrives before it has started the prompt and runs the
410+
* turn to completion.
411+
*/
412+
readonly cancelAfterPromptStarts?: boolean;
406413
/**
407414
* Kill and respawn the ACP child process before the next `session/prompt` after a
408415
* user interrupt. Grok can keep `task_already_running` state until the process exits.
@@ -1228,6 +1235,10 @@ interface ActiveAcpTurn {
12281235
* complete this; settled-soft classification ORs it with `promptSettled`.
12291236
*/
12301237
readonly promptWireSettled: Deferred.Deferred<void, never>;
1238+
/** Completed once `session/prompt` is the runtime's active prompt. */
1239+
readonly promptDispatched: Deferred.Deferred<void, never>;
1240+
/** Completed by the first prompt-scoped `session/update` after dispatch. */
1241+
readonly promptStarted: Deferred.Deferred<void, never>;
12311242
backgroundFinalizeGeneration: number;
12321243
}
12331244

@@ -5431,10 +5442,27 @@ export function makeAcpAdapterV2(
54315442
}
54325443
});
54335444

5445+
// Session-scoped updates (late setup echoes) do not show that the
5446+
// agent has started the prompt.
5447+
const markPromptStarted = Effect.fnUntraced(function* (
5448+
notification: EffectAcpSchema.SessionNotification,
5449+
) {
5450+
const context = yield* Ref.get(activeTurn);
5451+
if (context === null || context.nativeThreadId !== notification.sessionId) return;
5452+
if (!(yield* Deferred.isDone(context.promptDispatched))) return;
5453+
switch (notification.update.sessionUpdate) {
5454+
case "available_commands_update":
5455+
case "config_option_update":
5456+
case "current_mode_update":
5457+
return;
5458+
}
5459+
yield* Deferred.succeed(context.promptStarted, undefined);
5460+
});
54345461
const projectAcpRuntimeSessionUpdateEffect = (
54355462
rawNotification: EffectAcpSchema.SessionNotification,
54365463
) =>
54375464
Effect.gen(function* () {
5465+
yield* markPromptStarted(rawNotification);
54385466
if (clientTerminals !== undefined) {
54395467
const embedded = embeddedTerminalIdsFromSessionUpdate(rawNotification);
54405468
if (embedded !== undefined) {
@@ -6536,6 +6564,19 @@ export function makeAcpAdapterV2(
65366564
}
65376565
});
65386566

6567+
/**
6568+
* `cancelAfterPromptStarts`: waits until the agent has started the
6569+
* prompt. False when the prompt settled first, so there is nothing to
6570+
* cancel. A hold that outlives the interrupt timeout cancels anyway.
6571+
*/
6572+
const promptStillRunsAfterHold = Effect.fnUntraced(function* (context: ActiveAcpTurn) {
6573+
if (flavor.cancelAfterPromptStarts !== true) return true;
6574+
const started = yield* Effect.raceFirst(
6575+
Deferred.await(context.promptStarted).pipe(Effect.as(true)),
6576+
Deferred.await(context.completed).pipe(Effect.as(false)),
6577+
).pipe(Effect.timeoutOption("10 seconds"));
6578+
return Option.getOrElse(started, () => true);
6579+
});
65396580
const quarantineStoppedRun = Effect.fnUntraced(function* () {
65406581
yield* continuationPermit.withPermit(
65416582
Effect.gen(function* () {
@@ -6962,6 +7003,8 @@ export function makeAcpAdapterV2(
69627003
const providerTurnId = deriveProviderTurnId(nativeTurnId);
69637004
const completed = yield* Deferred.make<void, never>();
69647005
const promptWireSettled = yield* Deferred.make<void, never>();
7006+
const promptDispatched = yield* Deferred.make<void, never>();
7007+
const promptStarted = yield* Deferred.make<void, never>();
69657008
const rememberedContextUsage = (yield* Ref.get(contextUsageBySessionId)).get(
69667009
requestedSessionId,
69677010
);
@@ -7010,6 +7053,8 @@ export function makeAcpAdapterV2(
70107053
promptSettled: false,
70117054
promptSettledStatus: null,
70127055
promptWireSettled,
7056+
promptDispatched,
7057+
promptStarted,
70137058
backgroundFinalizeGeneration: 0,
70147059
};
70157060
const carryover = yield* Ref.getAndSet(carryoverSubagents, null);
@@ -7140,7 +7185,8 @@ export function makeAcpAdapterV2(
71407185
return;
71417186
}
71427187
const promptGeneration = yield* Ref.get(runtimeCallbackGeneration);
7143-
yield* runtime.prompt({ prompt: promptParts!.prompt }).pipe(
7188+
const promptOptions = { dispatched: context.promptDispatched };
7189+
yield* runtime.prompt({ prompt: promptParts!.prompt }, promptOptions).pipe(
71447190
Effect.tap(() =>
71457191
Ref.update(promptInstructionStates, (current) => {
71467192
if (promptParts?.instructionState === undefined) return current;
@@ -7692,7 +7738,7 @@ export function makeAcpAdapterV2(
76927738
// acknowledge and would only threaten still-running
76937739
// background subagents. Skip it and leave the runtime
76947740
// untouched for the replacement turn.
7695-
if (!settledSoftInterrupt) {
7741+
if (!settledSoftInterrupt && (yield* promptStillRunsAfterHold(context))) {
76967742
yield* runtime.cancel;
76977743
}
76987744
if (restartRuntime && flavor.restartRuntimeAfterInterrupt === true) {

‎apps/server/src/orchestration-v2/Adapters/KiroAdapterV2.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import {
1717
import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
1818
import * as Crypto from "effect/Crypto";
1919
import * as DateTime from "effect/DateTime";
20+
import * as Deferred from "effect/Deferred";
2021
import * as Effect from "effect/Effect";
2122
import * as Exit from "effect/Exit";
23+
import * as Fiber from "effect/Fiber";
2224
import * as FileSystem from "effect/FileSystem";
2325
import * as Layer from "effect/Layer";
2426
import * as Path from "effect/Path";
@@ -391,6 +393,79 @@ describe("KiroAdapterV2", () => {
391393
);
392394
});
393395

396+
describe("KiroAdapterV2 early Stop", () => {
397+
// Kiro CLI 2.27.1 drops a `session/cancel` that arrives 0-0.5 s after
398+
// `session/prompt` and runs the turn to completion (reported by TinBane).
399+
// These scripts hold Kiro's first frame for the prompt until after Stop was
400+
// pressed, so a cancel sent before it reaches the script as a frame it does
401+
// not expect.
402+
const promptStarted = {
403+
...update({
404+
sessionUpdate: "session_info_update",
405+
_meta: { kiro: { userMessageId: "message-1", kind: "user_message_id_assigned" } },
406+
}),
407+
afterMs: 300,
408+
};
409+
const runningProviderTurnId = (event: ProviderAdapterV2Event) =>
410+
event.type === "provider_turn.updated" && event.providerTurn.status === "running"
411+
? event.providerTurn.id
412+
: undefined;
413+
const stopBeforeKiroStarts = (frames: ReadonlyArray<Frame>) =>
414+
runKiroScript({
415+
scenario: "early-stop",
416+
frames: [
417+
...openSessionFrames({
418+
initialize: KIRO_V3_INITIALIZE,
419+
configOptions: [modeOption, autopilotOption("on")],
420+
}),
421+
turnPrompt,
422+
...frames,
423+
...closeSession,
424+
],
425+
drive: ({ events, startTurn, interrupt }) =>
426+
Effect.gen(function* () {
427+
const running = yield* Deferred.make<ProviderTurnId>();
428+
const turn = yield* collectTurn(
429+
events.pipe(
430+
Stream.tap((event) => {
431+
const providerTurnId = runningProviderTurnId(event);
432+
return providerTurnId === undefined
433+
? Effect.void
434+
: Deferred.succeed(running, providerTurnId);
435+
}),
436+
),
437+
).pipe(Effect.forkChild);
438+
yield* startTurn;
439+
yield* interrupt(yield* Deferred.await(running));
440+
// Stop decides the run's outcome; the script fails on any cancel it
441+
// does not expect.
442+
assert.equal(terminalStatus(yield* Fiber.join(turn)), "interrupted");
443+
}),
444+
}).pipe(Effect.provide(testLayer), Effect.scoped);
445+
446+
it.live("holds Stop until Kiro starts the prompt, then cancels it", () =>
447+
stopBeforeKiroStarts([
448+
promptStarted,
449+
{
450+
type: "expect_outbound",
451+
label: "session/cancel",
452+
frame: {
453+
kind: "notification",
454+
method: "session/cancel",
455+
params: { sessionId: SESSION_ID },
456+
},
457+
},
458+
answer("session/prompt", { stopReason: "cancelled" }),
459+
]),
460+
);
461+
462+
it.live("sends no cancel when Kiro finishes the prompt before starting it", () =>
463+
stopBeforeKiroStarts([
464+
{ ...answer("session/prompt", { stopReason: "end_turn" }), afterMs: 300 },
465+
]),
466+
);
467+
});
468+
394469
describe("KiroAdapterV2 unknown model", () => {
395470
// Recorded live from Kiro 2.27.0 with the thread on a model id the account
396471
// lacks. Kiro has not advertised `model` yet, so T3 sends the id; Kiro

‎apps/server/src/orchestration-v2/Adapters/KiroAdapterV2.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ function makeKiroAcpAdapterFlavor(options: KiroAdapterV2Options): AcpAdapterV2Fl
162162
sessionConfigForPolicy: (policy) => [
163163
{ id: KIRO_AUTOPILOT_CONFIG_ID, value: kiroAutopilotValue(policy) },
164164
],
165+
// Kiro 2.27 drops a `session/cancel` sent before it starts the prompt
166+
// (live: 0-0.5 s after `session/prompt`) and runs the turn to completion.
167+
cancelAfterPromptStarts: true,
165168
permissionDisposition: kiroPermissionDisposition,
166169
approvalOptions: kiroApprovalOptions,
167170
// Kiro V3 advertises `promptCapabilities.image`; the shared adapter reads it.

0 commit comments

Comments
 (0)