diff --git a/ROADMAP.md b/ROADMAP.md index 4804f4a..bfbea34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1837,7 +1837,7 @@ The checked TUI items in this phase were pulled forward as an explicit exception - [ ] Reconcile uncertain submissions before retrying and implement provider cancellation where available. - [ ] Keep steering and follow-ups queued until the pending batch request reaches a terminal state. - [x] Implement daemon-owned steer and follow-up semantics at complete tool-call and turn boundaries. -- [ ] Complete interrupt-and-deliver semantics. +- [x] Complete interrupt-and-deliver semantics. - [x] Queue multiple follow-ups in order. - [x] Add `/fork` from a selected user message and `/clone` from the current tip. - [ ] Add in-session branch and tree navigation. diff --git a/SETUP.md b/SETUP.md index 0eef045..c948ae6 100644 --- a/SETUP.md +++ b/SETUP.md @@ -66,7 +66,7 @@ axl daemon stop --interrupt --yes Use the same `--unsafe`, `--sandbox`, `--image`, or `--socket` selection as the running daemon. Status and stop do not require provider credentials. Restart refuses to switch data directories. Stop and restart refuse active work unless `--interrupt` explicitly authorizes cancellation. `--yes` confirms disconnecting clients. A changed confirmation snapshot requires a fresh command. Exit codes are 0 for success, 1 for errors, 2 for refused or stale confirmation, and 3 for a missing daemon on status or stop. Restart starts a missing daemon. -An incompatible session wire fails loudly and points to these commands. No daemon is automatically replaced on a version mismatch. Host-control version 1 operates independently of session wire version 11 on a separate connection to the same owner-only Unix socket. It does not bypass the session handshake. +An incompatible session wire fails loudly and points to these commands. No daemon is automatically replaced on a version mismatch. Host-control version 1 operates independently of session wire version 12 on a separate connection to the same owner-only Unix socket. It does not bypass the session handshake. If graceful cleanup fails or exceeds the host's ten-second wait, inspect `axl daemon status`. Shutdown can still be running. The TUI offers a separate force confirmation when available. From the CLI, explicitly request: diff --git a/docs/architecture/web-protocol.md b/docs/architecture/web-protocol.md index aeae762..7bcad5e 100644 --- a/docs/architecture/web-protocol.md +++ b/docs/architecture/web-protocol.md @@ -13,7 +13,7 @@ This document specifies typed RPC, negotiation, errors, package ownership, and t ## Current baseline -Wire version 11 uses newline-delimited JSON over a Unix socket. It includes typed request and result envelopes, initialization, capability negotiation, structured errors, idempotency keys, subscription identities, paged snapshots, acknowledged opaque cursors, presence, daemon security reporting, direct shell events, transient activity, session-bound blobs, workspace review, session profiles, web-tool selection, manual compaction, steering, follow-ups, and canonical model-retry attempts. +Wire version 12 uses newline-delimited JSON over a Unix socket. It includes typed request and result envelopes, initialization, capability negotiation, structured errors, idempotency keys, subscription identities, paged snapshots, acknowledged opaque cursors, presence, daemon security reporting, direct shell events, transient activity, session-bound blobs, workspace review, session profiles, web-tool selection, manual compaction, steering, follow-ups, atomic interrupt-and-deliver, and canonical model-retry attempts. The TUI consumes these contracts through `packages/sdk`. Version 11 adds daemon-owned model request settings plus canonical effective-request events. Host-control version 1 remains separate from session wire negotiation and is available only to trusted process hosts. @@ -61,8 +61,9 @@ session.resume session.fork session.clone session.send.prompt -session.send.steer -session.send.follow_up +session.steer +session.follow_up +session.interrupt_deliver session.queue.enqueue session.queue.requeue session.shell @@ -227,6 +228,7 @@ The following table lists the additional errors each method may return. The expo | `session.fork` | `unknown_session`, `event_migration_required`, `corrupt_session`, `operation_active`, `invalid_fork_point`, `invalid_idempotency_key`, `idempotency_conflict`, `content_too_large` | | `session.clone` | `unknown_session`, `event_migration_required`, `corrupt_session`, `operation_active`, `empty_session`, `invalid_idempotency_key`, `idempotency_conflict`, `content_too_large` | | `session.send` | `unknown_session`, `event_migration_required`, `operation_active`, `invalid_idempotency_key`, `idempotency_conflict`, `blob_not_owned`, `blob_missing`, `blob_corrupt`, `content_too_large` | +| `session.interruptAndDeliver` | `unknown_session`, `event_migration_required`, `operation_active`, `invalid_idempotency_key`, `idempotency_conflict`, `blob_not_owned`, `blob_missing`, `blob_corrupt`, `content_too_large` | | `session.queue.enqueue` | `unknown_session`, `event_migration_required`, `invalid_idempotency_key`, `idempotency_conflict`, `blob_not_owned`, `blob_missing`, `blob_corrupt`, `content_too_large` | | `session.queue.requeue` | `unknown_session`, `event_migration_required`, `unknown_queue_item`, `queue_not_paused`, `invalid_idempotency_key`, `idempotency_conflict`, `content_too_large` | | `session.shell` | `unknown_session`, `event_migration_required`, `operation_active`, `idempotency_conflict`, `content_too_large` | @@ -401,7 +403,7 @@ interface SessionDisposeResult { `delivery: "prompt"` is ordinary prompt behavior. The version-7 `session.steer` and `session.followUp` methods remain available in version 8. The `session.send` delivery variants `steer` and `follow_up` remain unavailable until their separate capabilities are implemented, and clients must not simulate them. -`session.send` completes when the turn reaches a canonical terminal assistant event or error. Detaching does not cancel it. `session.interrupt` is the session-operation cancellation path. +`session.send` completes when the turn reaches a canonical terminal assistant event or error. Detaching does not cancel it. `session.interrupt` is the session-operation cancellation path. `session.interruptAndDeliver` atomically stops active work at a safe boundary and delivers its replacement content exactly once; when no operation is active, it behaves as an ordinary send. Queued prompts use `session.queue.enqueue` and `session.queue.requeue`. Enqueue records prompt content and priority in canonical history before returning. The daemon appends lifecycle events as an item is queued, started, paused after restart, and explicitly re-queued. Pending items are never executed automatically after restart. Every attachment derives the same queue from those events. diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index 8d676f6..ebe2ac6 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -850,6 +850,23 @@ export class AxlDaemon { } } + private interruptTargetOperationId( + sessionId: SessionId, + ): ReturnType | undefined { + const active = this.sessions.activeOperationId(sessionId); + if (active !== undefined) return active; + for (const request of this.admitted.values()) { + if (request.method === "session.send" && request.params.sessionId === sessionId) { + const operationId = request.idempotencyKey; + if (operationId !== undefined) return parseOperationId(operationId, "idempotencyKey"); + } + if (request.method === "session.shell" && request.params.sessionId === sessionId) { + return request.params.operationId; + } + } + return undefined; + } + private async executeRequest( request: WireRequest, send: (message: ServerMessage) => void, @@ -908,25 +925,43 @@ export class AxlDaemon { ? parseSessionId(randomUUID(), "intendedSessionId") : undefined; const affectedOperationId = - normalized.method === "session.interrupt" - ? this.sessions.activeOperationId(normalized.params.sessionId) + normalized.method === "session.interrupt" || + normalized.method === "session.interruptAndDeliver" + ? this.interruptTargetOperationId(normalized.params.sessionId) : undefined; const interactionId = normalized.method === "session.interaction.respond" ? normalized.params.interactionId : undefined; - return journal.execute( - { - idempotencyKey, - method: normalized.method as RetryableMutationMethod, - requestHash: hashCanonicalRequest(normalized.method, normalized.params as never), - ...(params.sessionId === undefined ? {} : { targetSessionId: params.sessionId }), - ...(intendedSessionId === undefined ? {} : { intendedSessionId }), - ...(affectedOperationId === undefined ? {} : { affectedOperationId }), - ...(interactionId === undefined ? {} : { interactionId }), - }, - (acceptance) => this.dispatch(normalized, send, state, acceptance) as never, - ); + const interruptDeliveryOperationId = + normalized.method === "session.interruptAndDeliver" + ? parseOperationId(idempotencyKey, "idempotencyKey") + : undefined; + if (interruptDeliveryOperationId !== undefined) { + this.sessions.reserveInterruptDelivery( + params.sessionId, + interruptDeliveryOperationId, + affectedOperationId, + ); + } + try { + return await journal.execute( + { + idempotencyKey, + method: normalized.method as RetryableMutationMethod, + requestHash: hashCanonicalRequest(normalized.method, normalized.params as never), + ...(params.sessionId === undefined ? {} : { targetSessionId: params.sessionId }), + ...(intendedSessionId === undefined ? {} : { intendedSessionId }), + ...(affectedOperationId === undefined ? {} : { affectedOperationId }), + ...(interactionId === undefined ? {} : { interactionId }), + }, + (acceptance) => this.dispatch(normalized, send, state, acceptance) as never, + ); + } finally { + if (interruptDeliveryOperationId !== undefined) { + this.sessions.releaseInterruptDelivery(params.sessionId, interruptDeliveryOperationId); + } + } } private async dispatch( @@ -1046,6 +1081,15 @@ export class AxlDaemon { return this.sessions.steer(request.params.sessionId, request.params.content); case "session.followUp": return this.sessions.followUp(request.params.sessionId, request.params.content); + case "session.interruptAndDeliver": + return this.sessions.interruptAndDeliver( + request.params.sessionId, + request.params.content, + this.mutationOperationId(acceptance), + acceptance?.affectedOperationId === undefined + ? undefined + : parseOperationId(acceptance.affectedOperationId, "affectedOperationId"), + ); case "session.compact": return this.sessions.compact(request.params.sessionId, request.params.instructions); case "session.queue.enqueue": @@ -1070,7 +1114,12 @@ export class AxlDaemon { request.params.excluded, ); case "session.interrupt": - return this.sessions.interrupt(request.params.sessionId); + return this.sessions.interrupt( + request.params.sessionId, + acceptance?.affectedOperationId === undefined + ? undefined + : parseOperationId(acceptance.affectedOperationId, "affectedOperationId"), + ); case "session.reload": return this.sessions.reload(request.params.sessionId, this.mutationOperationId(acceptance)); case "session.configure": { diff --git a/packages/daemon/src/event-migration.ts b/packages/daemon/src/event-migration.ts index eb2fdad..f049848 100644 --- a/packages/daemon/src/event-migration.ts +++ b/packages/daemon/src/event-migration.ts @@ -184,6 +184,7 @@ function eventBlobReferences(event: CanonicalEvent): readonly BlobReference[] { if ( event.type !== "user.message" && event.type !== "assistant.message" && + event.type !== "interrupt.requested" && event.type !== "user.shell" && event.type !== "tool.result" ) { @@ -199,6 +200,7 @@ function externalizeTextContent( if ( event.type !== "user.message" && event.type !== "assistant.message" && + event.type !== "interrupt.requested" && event.type !== "user.shell" && event.type !== "tool.result" ) { diff --git a/packages/daemon/src/session-artifact.ts b/packages/daemon/src/session-artifact.ts index 811ece6..14699a1 100644 --- a/packages/daemon/src/session-artifact.ts +++ b/packages/daemon/src/session-artifact.ts @@ -63,6 +63,7 @@ function blobReferences(events: readonly CanonicalEvent[]): readonly BlobReferen event.type !== "user.message" && event.type !== "assistant.message" && event.type !== "queue.enqueued" && + event.type !== "interrupt.requested" && event.type !== "user.shell" && event.type !== "tool.result" ) { diff --git a/packages/daemon/src/session-manager.ts b/packages/daemon/src/session-manager.ts index 94f2b69..eac7ee1 100644 --- a/packages/daemon/src/session-manager.ts +++ b/packages/daemon/src/session-manager.ts @@ -32,6 +32,7 @@ import { type ToolRegistry, } from "@axl/kernel"; import { + type AssistantStopReason, type BlobReference, type CanonicalEvent, EVENT_FORMAT_VERSION, @@ -175,6 +176,12 @@ interface ManagedSession { }; selection: SessionConfiguration; activeTurn?: ActiveTurn; + interruptDelivery?: { + readonly operationId: OperationId; + readonly targetOperationId?: OperationId; + }; + readonly interruptedForDelivery: Set; + readonly pendingInterrupts: Set; queuedInputs: Promise; rebuilding?: Promise; readonly interactions: Map; @@ -374,6 +381,7 @@ export class SessionManager { if ( event.type !== "user.message" && event.type !== "assistant.message" && + event.type !== "interrupt.requested" && event.type !== "user.shell" && event.type !== "tool.result" ) { @@ -492,6 +500,8 @@ export class SessionManager { activityListeners, activityState, selection, + interruptedForDelivery: new Set(), + pendingInterrupts: new Set(), queuedInputs: Promise.resolve(), interactions: new Map(), queue: [], @@ -594,6 +604,65 @@ export class SessionManager { } const stored = (await JsonlEventLog.open(this.logPath(target), target)).events; const evidence = stored.filter((event) => event.operationId === operationId); + if (acceptance.method === "session.interruptAndDeliver") { + const requested = evidence.find((event) => event.type === "interrupt.requested"); + if (requested?.type !== "interrupt.requested") return undefined; + const failed = evidence.findLast( + (event) => event.type === "interrupt.updated" && event.payload.state === "failed", + ); + if (failed !== undefined) { + return { + operationId, + stopReason: "error", + ...(requested.payload.targetOperationId === undefined + ? {} + : { targetOperationId: requested.payload.targetOperationId }), + }; + } + await this.resume(target); + const managed = this.managed(target); + let message = managed.events.find( + (event) => event.operationId === operationId && event.type === "user.message", + ); + let terminal = managed.events.findLast( + (event) => + event.operationId === operationId && + ((event.type === "assistant.message" && event.payload.stopReason !== "tool_use") || + event.type === "session.error"), + ); + if (message?.type !== "user.message") { + const recovered = await managed.session.abortRecoveredDelivery( + operationId, + requested.payload.content, + ); + message = recovered.message; + terminal = recovered.terminal; + } else if (terminal === undefined) { + terminal = await managed.session.abortRecoveredTurn(operationId); + } + if ( + !managed.events.some( + (event) => + event.operationId === operationId && + event.type === "interrupt.updated" && + event.payload.state === "delivered", + ) + ) { + await managed.session.recordInterruptEvent(operationId, "interrupt.updated", { + state: "delivered", + ...(requested.payload.targetOperationId === undefined + ? {} + : { targetOperationId: requested.payload.targetOperationId }), + }); + } + return { + operationId, + stopReason: terminal?.type === "assistant.message" ? terminal.payload.stopReason : "error", + ...(requested.payload.targetOperationId === undefined + ? {} + : { targetOperationId: requested.payload.targetOperationId }), + }; + } if (acceptance.method === "session.interrupt") { const affected = acceptance.affectedOperationId === undefined @@ -694,7 +763,33 @@ export class SessionManager { activeOperationId(sessionId: unknown): OperationId | undefined { const parsed = parseSessionId(sessionId, "sessionId"); - return this.sessions.get(parsed)?.activeTurn?.operationId; + const managed = this.sessions.get(parsed); + return managed?.activeTurn?.operationId ?? managed?.interruptDelivery?.operationId; + } + + reserveInterruptDelivery( + sessionId: unknown, + operationId: OperationId, + targetOperationId?: OperationId, + ): void { + const managed = this.managed(sessionId); + if ( + managed.interruptDelivery !== undefined && + managed.interruptDelivery.operationId !== operationId + ) { + throw new DaemonError("operation_active", "Another interrupt delivery owns this branch"); + } + managed.interruptDelivery = { + operationId, + ...(targetOperationId === undefined ? {} : { targetOperationId }), + }; + } + + releaseInterruptDelivery(sessionId: unknown, operationId: OperationId): void { + const managed = this.managed(sessionId); + if (managed.interruptDelivery?.operationId !== operationId) return; + delete managed.interruptDelivery; + this.startQueueDrain(managed); } describe(sessionId: unknown): SessionOpenResult { @@ -708,7 +803,9 @@ export class SessionManager { ? "disposing" : managed.interactions.size > 0 ? "waiting_interaction" - : managed.activeTurn !== undefined || managed.rebuilding !== undefined + : managed.activeTurn !== undefined || + managed.rebuilding !== undefined || + managed.interruptDelivery !== undefined ? "running" : "idle", ...(activeOperationId === undefined ? {} : { activeOperationId }), @@ -759,7 +856,11 @@ export class SessionManager { const parsed = parseSessionId(sessionId, "sessionId"); this.assertNotQuarantined(parsed); const active = this.sessions.get(parsed); - if (active?.activeTurn !== undefined || active?.rebuilding !== undefined) { + if ( + active?.activeTurn !== undefined || + active?.rebuilding !== undefined || + active?.interruptDelivery !== undefined + ) { throw new DaemonError("operation_active", "Export the session after its active operation"); } let events: readonly CanonicalEvent[]; @@ -847,7 +948,7 @@ export class SessionManager { const sourceId = parseSessionId(sessionId, "sessionId"); await this.resume(sourceId); const source = this.managed(sourceId); - if (source.activeTurn || source.rebuilding) { + if (source.activeTurn || source.rebuilding || source.interruptDelivery !== undefined) { throw new DaemonError("operation_active", "An operation owns this session; fork after it"); } const eventId = parseEventId(fromEventId, "fromEventId"); @@ -869,7 +970,7 @@ export class SessionManager { const sourceId = parseSessionId(sessionId, "sessionId"); await this.resume(sourceId); const source = this.managed(sourceId); - if (source.activeTurn || source.rebuilding) { + if (source.activeTurn || source.rebuilding || source.interruptDelivery !== undefined) { throw new DaemonError("operation_active", "An operation owns this session; clone after it"); } const tip = source.events.at(-1)?.id; @@ -1094,7 +1195,7 @@ export class SessionManager { operationId?: OperationId, ): Promise<{ boundaryEventIds: readonly EventId[] }> { const managed = this.managed(sessionId); - if (managed.activeTurn || managed.rebuilding) { + if (managed.activeTurn || managed.rebuilding || managed.interruptDelivery !== undefined) { throw new DaemonError("operation_active", "An operation owns this branch; reload after it"); } if (operationId !== undefined) { @@ -1127,7 +1228,7 @@ export class SessionManager { const recovered = managed.events.filter((event) => event.operationId === operationId); if (recovered.length > 0) return this.configurationResult(managed, recovered); } - if (managed.activeTurn || managed.rebuilding) { + if (managed.activeTurn || managed.rebuilding || managed.interruptDelivery !== undefined) { throw new DaemonError( "operation_active", "An operation owns this branch; change configuration after it", @@ -1300,6 +1401,163 @@ export class SessionManager { return this.queueInput(sessionId, content, "followUp"); } + async interruptAndDeliver( + sessionId: unknown, + content: readonly UserContent[], + operationId: OperationId | undefined, + acceptedTargetOperationId?: OperationId, + ): Promise<{ + operationId: OperationId; + stopReason: AssistantStopReason; + targetOperationId?: OperationId; + }> { + if (operationId === undefined) { + throw new DaemonError("internal_error", "Interrupt delivery operation ID is missing"); + } + this.assertRunning(); + const managed = this.managed(sessionId); + const evidence = managed.events.filter((event) => event.operationId === operationId); + const delivered = evidence.findLast( + (event) => event.type === "interrupt.updated" && event.payload.state === "delivered", + ); + const terminal = evidence.findLast( + (event) => + (event.type === "assistant.message" && event.payload.stopReason !== "tool_use") || + event.type === "session.error", + ); + if (delivered?.type === "interrupt.updated" && terminal !== undefined) { + return { + operationId, + stopReason: terminal.type === "assistant.message" ? terminal.payload.stopReason : "error", + ...(acceptedTargetOperationId === undefined + ? {} + : { targetOperationId: acceptedTargetOperationId }), + }; + } + if ( + managed.interruptDelivery !== undefined && + managed.interruptDelivery.operationId !== operationId + ) { + throw new DaemonError("operation_active", "Another interrupt delivery owns this branch"); + } + for (const item of content) { + if (item.type !== "blob") continue; + try { + await this.blobs.assertOwned(managed.session.log.sessionId, item.blob); + } catch (error) { + if (error instanceof BlobStoreError) { + throw new DaemonError(error.code, error.message, { cause: error }); + } + throw error; + } + } + + managed.interruptDelivery = { + operationId, + ...(acceptedTargetOperationId === undefined + ? {} + : { targetOperationId: acceptedTargetOperationId }), + }; + let requested = evidence.find((event) => event.type === "interrupt.requested"); + try { + if (requested?.type !== "interrupt.requested") { + requested = await managed.session.recordInterruptEvent(operationId, "interrupt.requested", { + state: "queued", + content, + ...(acceptedTargetOperationId === undefined + ? {} + : { targetOperationId: acceptedTargetOperationId }), + }); + } + + const target = acceptedTargetOperationId; + if (target !== undefined) { + const alreadyInterrupting = evidence.some( + (event) => event.type === "interrupt.updated" && event.payload.state === "interrupting", + ); + if (!alreadyInterrupting) { + await managed.session.recordInterruptEvent(operationId, "interrupt.updated", { + state: "interrupting", + targetOperationId: target, + }); + } + managed.interruptedForDelivery.add(target); + if (managed.activeTurn?.operationId === target) { + const active = managed.activeTurn; + active.controller.abort(); + await active.done; + } else { + const targetEvents = managed.events.filter((event) => event.operationId === target); + const targetTerminal = targetEvents.some( + (event) => + (event.type === "assistant.message" && event.payload.stopReason !== "tool_use") || + event.type === "session.error", + ); + if (!targetTerminal && targetEvents.some((event) => event.type === "user.message")) { + await managed.session.abortRecoveredTurn(target); + } + } + managed.interruptedForDelivery.delete(target); + } + + const existingMessage = managed.events.find( + (event) => event.operationId === operationId && event.type === "user.message", + ); + const result = await this.send(managed.session.log.sessionId, content, operationId); + const message = + existingMessage ?? + managed.events.find( + (event) => event.operationId === operationId && event.type === "user.message", + ); + if (message?.type !== "user.message") { + throw new DaemonError("corrupt_session", "Interrupt replacement message was not recorded"); + } + if ( + !managed.events.some( + (event) => + event.type === "interrupt.updated" && + event.payload.state === "delivered" && + event.operationId === operationId, + ) + ) { + await managed.session.recordInterruptEvent(operationId, "interrupt.updated", { + state: "delivered", + ...(target === undefined ? {} : { targetOperationId: target }), + }); + } + return { + operationId, + stopReason: result.stopReason as AssistantStopReason, + ...(target === undefined ? {} : { targetOperationId: target }), + }; + } catch (error) { + if ( + requested?.type === "interrupt.requested" && + !managed.events.some( + (event) => + event.type === "interrupt.updated" && + event.payload.state === "failed" && + event.operationId === operationId, + ) + ) { + await managed.session.recordInterruptEvent(operationId, "interrupt.updated", { + state: "failed", + reason: error instanceof Error ? error.message : "Interrupt delivery failed", + ...(acceptedTargetOperationId === undefined + ? {} + : { targetOperationId: acceptedTargetOperationId }), + }); + } + throw error; + } finally { + managed.pendingInterrupts.delete(operationId); + if (acceptedTargetOperationId !== undefined) { + managed.interruptedForDelivery.delete(acceptedTargetOperationId); + } + this.releaseInterruptDelivery(managed.session.log.sessionId, operationId); + } + } + private queueInput( sessionId: unknown, content: readonly UserContent[], @@ -1369,11 +1627,18 @@ export class SessionManager { } this.assertRunning(); if (managed.disposing) throw new DaemonError("cancelled", "Session is being disposed"); - if (managed.activeTurn || managed.rebuilding) { + if ( + managed.activeTurn || + managed.rebuilding || + (managed.interruptDelivery !== undefined && + managed.interruptDelivery.operationId !== operationId && + managed.interruptDelivery.targetOperationId !== operationId) + ) { throw new DaemonError("operation_active", "An operation already owns this branch"); } const active = deferredTurn("turn", operationId); managed.activeTurn = active; + if (managed.pendingInterrupts.delete(active.operationId)) active.controller.abort(); try { await this.captureWorkspaceCheckpoint(managed); let result = await managed.session.runTurn( @@ -1381,12 +1646,18 @@ export class SessionManager { active.controller.signal, active.operationId, ); - while (!this.stopping && !managed.disposing && managed.session.hasQueuedMessages()) { + while ( + !this.stopping && + !managed.disposing && + !managed.interruptedForDelivery.has(active.operationId) && + managed.session.hasQueuedMessages() + ) { active.controller = new AbortController(); result = (await managed.session.continueQueued(active.controller.signal)) ?? result; } return { operationId: active.operationId, stopReason: result.stopReason }; } finally { + managed.interruptedForDelivery.delete(active.operationId); if (managed.activeTurn === active) delete managed.activeTurn; active.finish(); this.startQueueDrain(managed); @@ -1394,7 +1665,13 @@ export class SessionManager { } private startQueueDrain(managed: ManagedSession): void { - if (this.stopping || managed.disposing || managed.queueDraining) return; + if ( + this.stopping || + managed.disposing || + managed.queueDraining || + managed.interruptDelivery !== undefined + ) + return; const draining = this.drainQueue(managed); managed.queueDrain = draining; // Keep the rejected promise observable by disposal, and report it immediately. @@ -1463,7 +1740,7 @@ export class SessionManager { async compact(sessionId: unknown, customInstructions?: string): Promise<{ eventId: EventId }> { const managed = this.managed(sessionId); - if (managed.activeTurn || managed.rebuilding) { + if (managed.activeTurn || managed.rebuilding || managed.interruptDelivery !== undefined) { throw new DaemonError("operation_active", "An operation already owns this branch"); } const active = deferredTurn("compaction"); @@ -1527,11 +1804,12 @@ export class SessionManager { excluded, }, }); - if (managed.activeTurn || managed.rebuilding) { + if (managed.activeTurn || managed.rebuilding || managed.interruptDelivery !== undefined) { throw new DaemonError("operation_active", "An operation already owns this branch"); } const active = deferredTurn("shell", operationId); managed.activeTurn = active; + if (managed.pendingInterrupts.delete(active.operationId)) active.controller.abort(); try { await this.captureWorkspaceCheckpoint(managed); const event = await managed.session.runShell( @@ -1553,7 +1831,7 @@ export class SessionManager { enabled: boolean, ): Promise<{ enabled: boolean; checkpointId?: string }> { const managed = this.managed(sessionId); - if (managed.activeTurn || managed.rebuilding) { + if (managed.activeTurn || managed.rebuilding || managed.interruptDelivery !== undefined) { throw new DaemonError( "operation_active", "Change workspace checkpoint capture after the active operation", @@ -1696,11 +1974,28 @@ export class SessionManager { } } - interrupt(sessionId: unknown): { interrupted: boolean; operationId?: OperationId } { - const active = this.managed(sessionId).activeTurn; - if (!active) return { interrupted: false }; - active.controller.abort(); - return { interrupted: true, operationId: active.operationId }; + interrupt( + sessionId: unknown, + acceptedTargetOperationId?: OperationId, + ): { interrupted: boolean; operationId?: OperationId } { + const managed = this.managed(sessionId); + const target = acceptedTargetOperationId ?? managed.activeTurn?.operationId; + if (target === undefined) return { interrupted: false }; + const terminal = managed.events.findLast( + (event) => + event.operationId === target && + ((event.type === "assistant.message" && event.payload.stopReason !== "tool_use") || + event.type === "session.error" || + event.type === "user.shell" || + event.type === "context.compacted"), + ); + if (terminal !== undefined) return { interrupted: false }; + if (managed.activeTurn?.operationId === target) { + managed.activeTurn.controller.abort(); + } else { + managed.pendingInterrupts.add(target); + } + return { interrupted: true, operationId: target }; } subscribe( @@ -1958,6 +2253,7 @@ export class SessionManager { private authorizeEventBlobs(sessionId: SessionId, event: CanonicalEvent): void { if ( event.type !== "user.message" && + event.type !== "interrupt.requested" && event.type !== "user.shell" && event.type !== "assistant.message" && event.type !== "tool.result" @@ -1976,6 +2272,7 @@ export class SessionManager { cwd: managed.cwd, busy: managed.activeTurn !== undefined || + managed.interruptDelivery !== undefined || managed.rebuilding !== undefined || managed.disposing || managed.queueDraining || diff --git a/packages/daemon/test/daemon.test.ts b/packages/daemon/test/daemon.test.ts index d5911ea..9af26c6 100644 --- a/packages/daemon/test/daemon.test.ts +++ b/packages/daemon/test/daemon.test.ts @@ -142,13 +142,14 @@ async function startDaemon( deliveryOptions: { readonly cursorLifetimeMs?: number; readonly retry?: ModelRetryOptions | false; + readonly tools?: () => ToolRegistry; } = {}, ): Promise<{ daemon: AxlDaemon; socketPath: string; dataDirectory: string; cwd: string }> { const directory = await mkdtemp(join(tmpdir(), "axl-daemon-")); context.after(() => rm(directory, { recursive: true, force: true })); const cwd = await realpath(directory); const socketPath = join(directory, "axl.sock"); - const { retry, ...daemonOptions } = deliveryOptions; + const { retry, tools, ...daemonOptions } = deliveryOptions; const daemon = new AxlDaemon({ socketPath, dataDirectory: join(directory, "data"), @@ -158,7 +159,7 @@ async function startDaemon( ...daemonOptions, runtime: () => ({ model: port, - tools: new ToolRegistry(), + tools: tools?.() ?? new ToolRegistry(), system: "You are Axl.", ...(retry === undefined ? {} : { retry }), }), @@ -2201,6 +2202,313 @@ test("interrupt aborts the active operation from another connection", async (con assert.equal(idle.interrupted, false); }); +test("interrupt preserves intent while a send is admitted but not yet active", async (context) => { + const model: ModelPort = { + stream(request) { + const last = request.messages.findLast((message) => message.role === "user"); + const text = + last?.role === "user" + ? last.content.map((item) => (item.type === "text" ? item.text : "")).join("") + : ""; + return (async function* (): AsyncGenerator { + if (text === "stop during admission") { + await new Promise((resolvePromise) => { + if (request.signal?.aborted) return resolvePromise(); + request.signal?.addEventListener("abort", () => resolvePromise(), { once: true }); + }); + yield { type: "aborted" }; + return; + } + yield { type: "text_delta", text: "next completed" }; + yield { type: "completed", stopReason: "stop", usage }; + })(); + }, + }; + const { socketPath, cwd } = await startDaemon(context, model); + const client = await connectUnixClient(socketPath); + context.after(() => client.close()); + const created = await client.request("session.create", { cwd }); + const operationId = "00000000-0000-4000-8000-000000000128"; + + const sending = client.request( + "session.send", + { + sessionId: created.sessionId, + delivery: "prompt", + content: [{ type: "text", text: "stop during admission" }], + }, + { idempotencyKey: operationId }, + ); + const interrupted = await client.request("session.interrupt", { + sessionId: created.sessionId, + }); + + assert.deepEqual(interrupted, { interrupted: true, operationId }); + assert.deepEqual(await sending, { operationId, stopReason: "aborted" }); + assert.equal( + ( + await client.request("session.send", { + sessionId: created.sessionId, + delivery: "prompt", + content: [{ type: "text", text: "next operation" }], + }) + ).stopReason, + "stop", + ); +}); + +test("interrupt and deliver aborts active work and starts the replacement exactly once", async (context) => { + let calls = 0; + const model: ModelPort = { + stream(request) { + calls += 1; + const call = calls; + return (async function* (): AsyncGenerator { + if (call === 1) { + await new Promise((resolvePromise) => { + if (request.signal?.aborted) return resolvePromise(); + request.signal?.addEventListener("abort", () => resolvePromise(), { once: true }); + }); + yield { type: "aborted" }; + return; + } + yield { type: "text_delta", text: "replacement answer" }; + yield { type: "completed", stopReason: "stop", usage }; + })(); + }, + }; + const { socketPath, cwd } = await startDaemon(context, model); + const sender = await connectUnixClient(socketPath); + const controller = await connectUnixClient(socketPath); + context.after(() => { + sender.close(); + controller.close(); + }); + + const created = await sender.request("session.create", { cwd }); + const active = sender.request( + "session.send", + { + sessionId: created.sessionId, + delivery: "prompt", + content: [{ type: "text", text: "obsolete work" }], + }, + { idempotencyKey: "00000000-0000-4000-8000-000000000130" }, + ); + const key = "00000000-0000-4000-8000-000000000131"; + const replacement = { + sessionId: created.sessionId, + content: [{ type: "text" as const, text: "do this instead" }], + }; + const delivered = await controller.request("session.interruptAndDeliver", replacement, { + idempotencyKey: key, + }); + assert.deepEqual(await active, { + operationId: "00000000-0000-4000-8000-000000000130", + stopReason: "aborted", + }); + assert.deepEqual(delivered, { + operationId: key, + stopReason: "stop", + targetOperationId: "00000000-0000-4000-8000-000000000130", + }); + assert.deepEqual( + await controller.request("session.interruptAndDeliver", replacement, { idempotencyKey: key }), + delivered, + ); + assert.equal(calls, 2); + + const history = await subscribeAll(controller, created.sessionId); + assert.deepEqual( + history.events.filter((event) => event.operationId === key).map((event) => event.type), + [ + "interrupt.requested", + "interrupt.updated", + "user.message", + "assistant.message", + "interrupt.updated", + ], + ); + assert.equal( + history.events.filter( + (event) => + event.operationId === key && + event.type === "user.message" && + event.payload.content.some( + (item) => item.type === "text" && item.text === "do this instead", + ), + ).length, + 1, + ); +}); + +test("interrupt and deliver preserves completed tool results and closes the active call", async (context) => { + let modelCalls = 0; + let toolCalls = 0; + let activeToolStarted!: () => void; + const toolStarted = new Promise((resolvePromise) => { + activeToolStarted = resolvePromise; + }); + const model: ModelPort = { + stream() { + modelCalls += 1; + const call = modelCalls; + return (async function* (): AsyncGenerator { + if (call === 1) { + yield { type: "tool_call", callId: "first", name: "work", input: {} }; + yield { type: "tool_call", callId: "second", name: "work", input: {} }; + yield { type: "completed", stopReason: "tool_use", usage }; + return; + } + yield { type: "text_delta", text: "replacement answer" }; + yield { type: "completed", stopReason: "stop", usage }; + })(); + }, + }; + const { socketPath, cwd } = await startDaemon(context, model, "sandboxed", undefined, undefined, { + tools: () => { + const tools = new ToolRegistry(); + tools.register({ + name: "work", + description: "Controlled test work", + inputSchema: { type: "object" }, + async execute(_input, signal) { + toolCalls += 1; + if (toolCalls === 1) { + return { content: [{ type: "text", text: "first complete" }], isError: false }; + } + activeToolStarted(); + await new Promise((resolvePromise) => { + if (signal.aborted) return resolvePromise(); + signal.addEventListener("abort", () => resolvePromise(), { once: true }); + }); + return { content: [{ type: "text", text: "second aborted" }], isError: true }; + }, + }); + return tools; + }, + }); + const client = await connectUnixClient(socketPath); + context.after(() => client.close()); + const created = await client.request("session.create", { cwd }); + const active = client.request("session.send", { + sessionId: created.sessionId, + delivery: "prompt", + content: [{ type: "text", text: "run both tools" }], + }); + await toolStarted; + + const delivered = await client.request("session.interruptAndDeliver", { + sessionId: created.sessionId, + content: [{ type: "text", text: "replace tool work" }], + }); + assert.equal((await active).stopReason, "aborted"); + assert.equal(delivered.stopReason, "stop"); + + const history = await subscribeAll(client, created.sessionId); + const calls = history.events.filter((event) => event.type === "tool.call"); + const results = history.events.filter((event) => event.type === "tool.result"); + assert.deepEqual( + calls.map((event) => event.payload.callId), + ["first", "second"], + ); + assert.deepEqual( + results.map((event) => event.payload.callId), + ["first", "second"], + ); + assert.equal( + history.events.filter( + (event) => + event.type === "user.message" && + event.payload.content.some( + (item) => item.type === "text" && item.text === "replace tool work", + ), + ).length, + 1, + ); +}); + +test("interrupt and deliver behaves as an ordinary send when the session is idle", async (context) => { + const { socketPath, cwd } = await startDaemon(context, replyPort()); + const client = await connectUnixClient(socketPath); + context.after(() => client.close()); + const created = await client.request("session.create", { cwd }); + const operationId = "00000000-0000-4000-8000-000000000132"; + + const result = await client.request( + "session.interruptAndDeliver", + { + sessionId: created.sessionId, + content: [{ type: "text", text: "start normally" }], + }, + { idempotencyKey: operationId }, + ); + assert.deepEqual(result, { operationId, stopReason: "stop" }); + const history = await subscribeAll(client, created.sessionId); + assert.deepEqual( + history.events.filter((event) => event.operationId === operationId).map((event) => event.type), + ["interrupt.requested", "user.message", "assistant.message", "interrupt.updated"], + ); +}); + +test("restart recovery delivers an accepted interrupt replacement exactly once", async (context) => { + const fixture = await startDaemon(context, replyPort()); + const client = await connectUnixClient(fixture.socketPath); + const created = await client.request("session.create", { cwd: fixture.cwd }); + const key = "00000000-0000-4000-8000-000000000133"; + const request = { + sessionId: created.sessionId, + content: [{ type: "text" as const, text: "survive restart" }], + }; + await client.request("session.interruptAndDeliver", request, { idempotencyKey: key }); + client.close(); + await fixture.daemon.stop(); + + await removeCommandCompletions(fixture.dataDirectory, new Set([key])); + const logPath = join(fixture.dataDirectory, "sessions", `${created.sessionId}.jsonl`); + const records = (await readFile(logPath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { type: string; operationId?: string }); + const interruptedBeforeDelivery = records.filter( + (record) => record.operationId !== key || record.type === "interrupt.requested", + ); + await writeFile( + logPath, + `${interruptedBeforeDelivery.map((record) => JSON.stringify(record)).join("\n")}\n`, + ); + + const restarted = new AxlDaemon({ + socketPath: fixture.socketPath, + dataDirectory: fixture.dataDirectory, + runtime: () => ({ model: replyPort(), tools: new ToolRegistry() }), + }); + await restarted.start(); + context.after(() => restarted.stop()); + const recovered = await connectUnixClient(fixture.socketPath); + context.after(() => recovered.close()); + assert.deepEqual( + await recovered.request("session.interruptAndDeliver", request, { idempotencyKey: key }), + { operationId: key, stopReason: "aborted" }, + ); + await recovered.request("session.resume", { sessionId: created.sessionId }); + const history = await subscribeAll(recovered, created.sessionId); + assert.equal( + history.events.filter((event) => event.operationId === key && event.type === "user.message") + .length, + 1, + ); + assert.equal( + history.events.filter( + (event) => + event.operationId === key && + event.type === "interrupt.updated" && + event.payload.state === "delivered", + ).length, + 1, + ); +}); + test("restart recovery preserves exact interrupt results", async (context) => { const fixture = await startDaemon(context, hangingPort()); const client = await connectUnixClient(fixture.socketPath); diff --git a/packages/kernel/src/agent-session.ts b/packages/kernel/src/agent-session.ts index 543d947..b2735bc 100644 --- a/packages/kernel/src/agent-session.ts +++ b/packages/kernel/src/agent-session.ts @@ -629,6 +629,28 @@ export class AgentSession { }); } + async abortRecoveredDelivery( + operationId: OperationId, + content: readonly UserContent[], + ): Promise<{ + readonly message: CanonicalEvent<"user.message">; + readonly terminal: CanonicalEvent<"assistant.message">; + }> { + if (this.activeOperation !== null) { + throw new OperationConflictError( + `Operation ${this.activeOperation} already owns this branch`, + ); + } + const appended: CanonicalEvent[] = []; + await this.appendUserMessage(operationId, content, appended); + const message = appended[0]; + if (message?.type !== "user.message") { + throw new Error("Recovered delivery did not append its user message"); + } + const terminal = await this.abortRecoveredTurn(operationId); + return { message, terminal }; + } + async close(operationId: OperationId): Promise> { if (this.activeOperation !== null) { throw new OperationConflictError( @@ -819,6 +841,15 @@ export class AgentSession { return this.append(operationId, type, payload); } + /** Appends daemon-owned interrupt-and-deliver lifecycle state. */ + recordInterruptEvent( + operationId: OperationId, + type: Type, + payload: EventPayloadMap[Type], + ): Promise> { + return this.append(operationId, type, payload); + } + recordSessionError( operationId: OperationId, payload: EventPayloadMap["session.error"], diff --git a/packages/protocol/scripts/generate-conformance.ts b/packages/protocol/scripts/generate-conformance.ts index 18ab6c4..965662f 100644 --- a/packages/protocol/scripts/generate-conformance.ts +++ b/packages/protocol/scripts/generate-conformance.ts @@ -50,6 +50,15 @@ const eventPayloads = { "queue.requeued": { queueItemId, priority: "front" }, "queue.started": { queueItemId }, "queue.paused": { queueItemId, reason: "daemon_restart" }, + "interrupt.requested": { + state: "queued", + content: [{ type: "text", text: "replacement" }], + targetOperationId: operationId, + }, + "interrupt.updated": { + state: "delivered", + targetOperationId: operationId, + }, "user.shell": { command: "pwd", content: [{ type: "text", text: "/workspace" }], @@ -183,6 +192,10 @@ const params = { "session.send": { sessionId, content: [{ type: "text", text: "hello" }], delivery: "prompt" }, "session.steer": { sessionId, content: [{ type: "text", text: "adjust" }] }, "session.followUp": { sessionId, content: [{ type: "text", text: "then summarize" }] }, + "session.interruptAndDeliver": { + sessionId, + content: [{ type: "text", text: "replace the current task" }], + }, "session.compact": { sessionId, instructions: "Focus on code changes" }, "session.queue.enqueue": { sessionId, @@ -287,6 +300,11 @@ const results = { "session.send": { operationId, stopReason: "stop" }, "session.steer": { queued: true }, "session.followUp": { queued: true }, + "session.interruptAndDeliver": { + operationId, + stopReason: "stop", + targetOperationId: operationId, + }, "session.compact": { eventId }, "session.queue.enqueue": { queueItemId: eventId, state: "queued" }, "session.queue.requeue": { queueItemId: eventId, state: "queued" }, diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 5871ac2..f3cbabd 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -11,9 +11,11 @@ import { type EventId, type JsonObject, type JsonValue, + type OperationId, ProtocolValidationError, parseEventEnvelope, parseEventId, + parseOperationId, parseSessionId, type SessionId, } from "./event-envelope.ts"; @@ -79,6 +81,16 @@ export type EventPayloadMap = { "queue.requeued": { readonly queueItemId: EventId; readonly priority: "front" | "back" }; "queue.started": { readonly queueItemId: EventId }; "queue.paused": { readonly queueItemId: EventId; readonly reason: "daemon_restart" }; + "interrupt.requested": { + readonly state: "queued"; + readonly content: readonly UserContent[]; + readonly targetOperationId?: OperationId; + }; + "interrupt.updated": { + readonly state: "interrupting" | "delivered" | "failed"; + readonly targetOperationId?: OperationId; + readonly reason?: string; + }; "user.shell": { readonly command: string; readonly content: readonly UserContent[]; @@ -373,6 +385,30 @@ const payloadParsers: { readonly [Type in EventType]: PayloadParser } = { choice(payload.reason, `${path}.reason`, ["daemon_restart"]); return payload; }, + "interrupt.requested": (payload, path) => { + exact(payload, path, ["state", "content"], ["targetOperationId"]); + choice(payload.state, `${path}.state`, ["queued"]); + validateContent(payload.content, `${path}.content`, false); + if (payload.targetOperationId !== undefined) { + parseOperationId(payload.targetOperationId, `${path}.targetOperationId`); + } + return payload; + }, + "interrupt.updated": (payload, path) => { + exact(payload, path, ["state"], ["targetOperationId", "reason"]); + const state = choice(payload.state, `${path}.state`, ["interrupting", "delivered", "failed"]); + if (payload.targetOperationId !== undefined) { + parseOperationId(payload.targetOperationId, `${path}.targetOperationId`); + } + optionalString(payload.reason, `${path}.reason`); + if (state === "interrupting" && payload.targetOperationId === undefined) { + validationError(`${path}.targetOperationId`, "is required while interrupting"); + } + if (state === "failed" && payload.reason === undefined) { + validationError(`${path}.reason`, "is required when failed"); + } + return payload; + }, "user.shell": (payload, path) => { exact(payload, path, ["command", "content", "isError", "excluded"]); string(payload.command, `${path}.command`); diff --git a/packages/protocol/src/version.ts b/packages/protocol/src/version.ts index 2d5c87c..4d75bdd 100644 --- a/packages/protocol/src/version.ts +++ b/packages/protocol/src/version.ts @@ -7,4 +7,4 @@ export const EVENT_FORMAT_VERSION = 1 as const; /** Version negotiated by local daemon clients. */ -export const WIRE_PROTOCOL_VERSION = 11 as const; +export const WIRE_PROTOCOL_VERSION = 12 as const; diff --git a/packages/protocol/src/wire.ts b/packages/protocol/src/wire.ts index 97ae82e..9a69807 100644 --- a/packages/protocol/src/wire.ts +++ b/packages/protocol/src/wire.ts @@ -616,6 +616,7 @@ export const WIRE_CAPABILITIES = [ "session.send.prompt", "session.steer", "session.follow_up", + "session.interrupt_deliver", "session.compact", "session.queue.enqueue", "session.queue.requeue", @@ -756,6 +757,14 @@ export interface RpcMethodMap { readonly params: { readonly sessionId: SessionId; readonly content: readonly UserContent[] }; readonly result: { readonly queued: true }; }; + readonly "session.interruptAndDeliver": { + readonly params: { readonly sessionId: SessionId; readonly content: readonly UserContent[] }; + readonly result: { + readonly operationId: OperationId; + readonly stopReason: AssistantStopReason; + readonly targetOperationId?: OperationId; + }; + }; readonly "session.compact": { readonly params: { readonly sessionId: SessionId; readonly instructions?: string }; readonly result: { readonly eventId: EventId }; @@ -893,6 +902,7 @@ export const RETRYABLE_MUTATION_METHODS = [ "session.clone", "session.import", "session.send", + "session.interruptAndDeliver", "session.queue.enqueue", "session.queue.requeue", "session.interrupt", @@ -922,6 +932,7 @@ export function requiredCapability(method: RpcMethod): CapabilityId | undefined } if (method === "session.send") return "session.send.prompt"; if (method === "session.followUp") return "session.follow_up"; + if (method === "session.interruptAndDeliver") return "session.interrupt_deliver"; return method; } export type RpcResult = RpcMethodMap[Method]["result"]; @@ -1617,7 +1628,11 @@ export function parseWireRequest(value: unknown): WireRequest { }, }; } - if (method === "session.steer" || method === "session.followUp") { + if ( + method === "session.steer" || + method === "session.followUp" || + method === "session.interruptAndDeliver" + ) { exact(params, "request.params", ["sessionId", "content"]); return { ...base, @@ -2264,6 +2279,31 @@ export function parseRpcResult( throw new ProtocolValidationError(`${path}.queued`, "must be true"); } parsed = { queued: true }; + } else if (method === "session.interruptAndDeliver") { + const result = object(value, path); + exact(result, path, ["operationId", "stopReason", "targetOperationId"]); + const reasons: readonly AssistantStopReason[] = [ + "stop", + "length", + "tool_use", + "error", + "aborted", + ]; + if (!reasons.includes(result.stopReason as AssistantStopReason)) { + throw new ProtocolValidationError(`${path}.stopReason`, "is not a valid stop reason"); + } + parsed = { + operationId: parseOperationId(result.operationId, `${path}.operationId`), + stopReason: result.stopReason, + ...(result.targetOperationId === undefined + ? {} + : { + targetOperationId: parseOperationId( + result.targetOperationId, + `${path}.targetOperationId`, + ), + }), + }; } else if (method === "session.compact") { const result = object(value, path); exact(result, path, ["eventId"]); @@ -2510,6 +2550,7 @@ export const RPC_METHODS = [ "session.send", "session.steer", "session.followUp", + "session.interruptAndDeliver", "session.compact", "session.queue.enqueue", "session.queue.requeue", @@ -2645,6 +2686,15 @@ export const RPC_METHOD_ERROR_CODES = { "blob_missing", "blob_corrupt", ], + "session.interruptAndDeliver": [ + ...SESSION_BASE_ERRORS, + "operation_active", + ...MUTATION_ERRORS, + "blob_not_owned", + "blob_missing", + "blob_corrupt", + "content_too_large", + ], "session.compact": [...SESSION_BASE_ERRORS, "operation_active", "content_too_large"], "session.queue.enqueue": [ ...SESSION_BASE_ERRORS, diff --git a/packages/protocol/test/events.test.ts b/packages/protocol/test/events.test.ts index afe96a6..6db4b48 100644 --- a/packages/protocol/test/events.test.ts +++ b/packages/protocol/test/events.test.ts @@ -15,11 +15,13 @@ import { ProtocolValidationError, parseEvent, parseEventId, + parseOperationId, parseSessionId, } from "../src/index.ts"; const eventId = parseEventId("018f47a5-4f18-7cc2-8000-123456789abc"); const secondEventId = parseEventId("018f47a5-4f18-7cc2-8000-123456789abd"); +const operationId = parseOperationId("018f47a5-4f18-7cc2-8000-123456789abe"); const sessionId = parseSessionId("123e4567-e89b-42d3-a456-426614174000"); const validPayloads = { @@ -31,6 +33,15 @@ const validPayloads = { "queue.requeued": { queueItemId: eventId, priority: "front" }, "queue.started": { queueItemId: eventId }, "queue.paused": { queueItemId: eventId, reason: "daemon_restart" }, + "interrupt.requested": { + state: "queued", + content: [{ type: "text", text: "replacement" }], + targetOperationId: operationId, + }, + "interrupt.updated": { + state: "delivered", + targetOperationId: operationId, + }, "user.shell": { command: "pwd", content: [{ type: "text", text: "/workspace" }], @@ -156,6 +167,8 @@ test("rejects invalid event payloads", () => { event("assistant.message", { content: [], stopReason: "error" }), event("context.compacted", { summary: "empty", replacedEventIds: [] }), event("permission.resolved", { requestId: "not-a-uuid", decision: "deny" }), + event("interrupt.updated", { state: "interrupting" }), + event("interrupt.updated", { state: "failed" }), event("config.profile", { profile: "unknown" }), event("config.request", { maxOutputTokens: 0, httpIdleTimeoutMs: 300_000 }), event("model.request_configured", { diff --git a/packages/protocol/test/fixtures/conformance.json b/packages/protocol/test/fixtures/conformance.json index 1fe83e3..e4e4d21 100644 --- a/packages/protocol/test/fixtures/conformance.json +++ b/packages/protocol/test/fixtures/conformance.json @@ -2,7 +2,7 @@ "SPDX-FileCopyrightText": "2026 Hari Srinivasan", "SPDX-License-Identifier": "Apache-2.0", "_generated": "@generated by packages/protocol/scripts/generate-conformance.ts; do not edit.", - "wireVersion": 11, + "wireVersion": 12, "requests": [ { "kind": "request", @@ -176,6 +176,21 @@ { "kind": "request", "id": 18, + "method": "session.interruptAndDeliver", + "params": { + "sessionId": "123e4567-e89b-42d3-a456-426614174000", + "content": [ + { + "type": "text", + "text": "replace the current task" + } + ] + }, + "idempotencyKey": "00000000-0000-4000-8000-000000000020" + }, + { + "kind": "request", + "id": 19, "method": "session.compact", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -184,7 +199,7 @@ }, { "kind": "request", - "id": 19, + "id": 20, "method": "session.queue.enqueue", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -200,7 +215,7 @@ }, { "kind": "request", - "id": 20, + "id": 21, "method": "session.queue.requeue", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -211,7 +226,7 @@ }, { "kind": "request", - "id": 21, + "id": 22, "method": "session.shell", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -222,7 +237,7 @@ }, { "kind": "request", - "id": 22, + "id": 23, "method": "session.interrupt", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000" @@ -231,7 +246,7 @@ }, { "kind": "request", - "id": 23, + "id": 24, "method": "session.reload", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000" @@ -240,7 +255,7 @@ }, { "kind": "request", - "id": 24, + "id": 25, "method": "session.configure", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -255,7 +270,7 @@ }, { "kind": "request", - "id": 25, + "id": 26, "method": "session.interaction.respond", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -269,7 +284,7 @@ }, { "kind": "request", - "id": 26, + "id": 27, "method": "session.subscribe", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000" @@ -277,7 +292,7 @@ }, { "kind": "request", - "id": 27, + "id": 28, "method": "session.workspace.list", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -287,7 +302,7 @@ }, { "kind": "request", - "id": 28, + "id": 29, "method": "session.workspace.read", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -298,7 +313,7 @@ }, { "kind": "request", - "id": 29, + "id": 30, "method": "session.workspace.status", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -307,7 +322,7 @@ }, { "kind": "request", - "id": 30, + "id": 31, "method": "session.workspace.diff", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -319,7 +334,7 @@ }, { "kind": "request", - "id": 31, + "id": 32, "method": "session.workspace.checkpoint", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -328,7 +343,7 @@ }, { "kind": "request", - "id": 32, + "id": 33, "method": "session.blob.start", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -339,7 +354,7 @@ }, { "kind": "request", - "id": 33, + "id": 34, "method": "session.blob.chunk", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -350,7 +365,7 @@ }, { "kind": "request", - "id": 34, + "id": 35, "method": "session.blob.commit", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -359,7 +374,7 @@ }, { "kind": "request", - "id": 35, + "id": 36, "method": "session.blob.abort", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -368,7 +383,7 @@ }, { "kind": "request", - "id": 36, + "id": 37, "method": "session.blob.read", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000", @@ -379,7 +394,7 @@ }, { "kind": "request", - "id": 37, + "id": 38, "method": "session.dispose", "params": { "sessionId": "123e4567-e89b-42d3-a456-426614174000" @@ -404,7 +419,7 @@ "result": { "attachmentId": "attachment-1", "daemonInstanceId": "daemon-1", - "wireVersion": 11, + "wireVersion": 12, "grantedCapabilities": ["session.create"], "scope": "local_control", "heartbeatIntervalMs": 20000, @@ -593,6 +608,16 @@ { "kind": "success", "id": 18, + "method": "session.interruptAndDeliver", + "result": { + "operationId": "00000000-0000-4000-8000-000000000010", + "stopReason": "stop", + "targetOperationId": "00000000-0000-4000-8000-000000000010" + } + }, + { + "kind": "success", + "id": 19, "method": "session.compact", "result": { "eventId": "00000000-0000-4000-8000-000000000001" @@ -600,7 +625,7 @@ }, { "kind": "success", - "id": 19, + "id": 20, "method": "session.queue.enqueue", "result": { "queueItemId": "00000000-0000-4000-8000-000000000001", @@ -609,7 +634,7 @@ }, { "kind": "success", - "id": 20, + "id": 21, "method": "session.queue.requeue", "result": { "queueItemId": "00000000-0000-4000-8000-000000000001", @@ -618,7 +643,7 @@ }, { "kind": "success", - "id": 21, + "id": 22, "method": "session.shell", "result": { "operationId": "00000000-0000-4000-8000-000000000010", @@ -628,7 +653,7 @@ }, { "kind": "success", - "id": 22, + "id": 23, "method": "session.interrupt", "result": { "interrupted": true, @@ -637,7 +662,7 @@ }, { "kind": "success", - "id": 23, + "id": 24, "method": "session.reload", "result": { "boundaryEventIds": ["00000000-0000-4000-8000-000000000001"] @@ -645,7 +670,7 @@ }, { "kind": "success", - "id": 24, + "id": 25, "method": "session.configure", "result": { "modelId": "model-1", @@ -663,7 +688,7 @@ }, { "kind": "success", - "id": 25, + "id": 26, "method": "session.interaction.respond", "result": { "interactionId": "interaction-1", @@ -672,7 +697,7 @@ }, { "kind": "success", - "id": 26, + "id": 27, "method": "session.subscribe", "result": { "subscriptionId": "subscription-1", @@ -704,7 +729,7 @@ }, { "kind": "success", - "id": 27, + "id": 28, "method": "session.workspace.list", "result": { "workspaceGeneration": "workspace-1", @@ -720,7 +745,7 @@ }, { "kind": "success", - "id": 28, + "id": 29, "method": "session.workspace.read", "result": { "workspaceGeneration": "workspace-1", @@ -736,7 +761,7 @@ }, { "kind": "success", - "id": 29, + "id": 30, "method": "session.workspace.status", "result": { "workspaceGeneration": "workspace-1", @@ -762,7 +787,7 @@ }, { "kind": "success", - "id": 30, + "id": 31, "method": "session.workspace.diff", "result": { "workspaceGeneration": "workspace-1", @@ -799,7 +824,7 @@ }, { "kind": "success", - "id": 31, + "id": 32, "method": "session.workspace.checkpoint", "result": { "enabled": true, @@ -808,7 +833,7 @@ }, { "kind": "success", - "id": 32, + "id": 33, "method": "session.blob.start", "result": { "uploadId": "upload-1", @@ -817,7 +842,7 @@ }, { "kind": "success", - "id": 33, + "id": 34, "method": "session.blob.chunk", "result": { "nextOffset": 4 @@ -825,7 +850,7 @@ }, { "kind": "success", - "id": 34, + "id": 35, "method": "session.blob.commit", "result": { "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", @@ -836,7 +861,7 @@ }, { "kind": "success", - "id": 35, + "id": 36, "method": "session.blob.abort", "result": { "aborted": true @@ -844,7 +869,7 @@ }, { "kind": "success", - "id": 36, + "id": 37, "method": "session.blob.read", "result": { "data": "YWJjZA==", @@ -855,7 +880,7 @@ }, { "kind": "success", - "id": 37, + "id": 38, "method": "session.dispose", "result": { "disposed": true, @@ -3476,326 +3501,486 @@ { "kind": "error", "id": 1701, - "method": "session.compact", + "method": "session.interruptAndDeliver", "error": { "code": "daemon_stopping", - "message": "Fixture session.compact error: daemon_stopping", + "message": "Fixture session.interruptAndDeliver error: daemon_stopping", "retryable": false } }, { "kind": "error", "id": 1702, - "method": "session.compact", + "method": "session.interruptAndDeliver", "error": { "code": "bad_request", - "message": "Fixture session.compact error: bad_request", + "message": "Fixture session.interruptAndDeliver error: bad_request", "retryable": false } }, { "kind": "error", "id": 1703, - "method": "session.compact", + "method": "session.interruptAndDeliver", "error": { "code": "not_initialized", - "message": "Fixture session.compact error: not_initialized", + "message": "Fixture session.interruptAndDeliver error: not_initialized", "retryable": false } }, { "kind": "error", "id": 1704, - "method": "session.compact", + "method": "session.interruptAndDeliver", "error": { "code": "unsupported_capability", - "message": "Fixture session.compact error: unsupported_capability", + "message": "Fixture session.interruptAndDeliver error: unsupported_capability", "retryable": false } }, { "kind": "error", "id": 1705, - "method": "session.compact", + "method": "session.interruptAndDeliver", "error": { "code": "rate_limited", - "message": "Fixture session.compact error: rate_limited", + "message": "Fixture session.interruptAndDeliver error: rate_limited", "retryable": true } }, { "kind": "error", "id": 1706, - "method": "session.compact", + "method": "session.interruptAndDeliver", "error": { "code": "internal_error", - "message": "Fixture session.compact error: internal_error", + "message": "Fixture session.interruptAndDeliver error: internal_error", "retryable": false } }, { "kind": "error", "id": 1707, - "method": "session.compact", + "method": "session.interruptAndDeliver", "error": { "code": "cancelled", - "message": "Fixture session.compact error: cancelled", + "message": "Fixture session.interruptAndDeliver error: cancelled", "retryable": false } }, { "kind": "error", "id": 1708, - "method": "session.compact", + "method": "session.interruptAndDeliver", "error": { "code": "unknown_session", - "message": "Fixture session.compact error: unknown_session", + "message": "Fixture session.interruptAndDeliver error: unknown_session", "retryable": false } }, { "kind": "error", "id": 1709, - "method": "session.compact", + "method": "session.interruptAndDeliver", "error": { "code": "event_migration_required", - "message": "Fixture session.compact error: event_migration_required", + "message": "Fixture session.interruptAndDeliver error: event_migration_required", "retryable": false } }, { "kind": "error", "id": 1710, - "method": "session.compact", + "method": "session.interruptAndDeliver", "error": { "code": "operation_active", - "message": "Fixture session.compact error: operation_active", + "message": "Fixture session.interruptAndDeliver error: operation_active", "retryable": false } }, { "kind": "error", "id": 1711, - "method": "session.compact", + "method": "session.interruptAndDeliver", "error": { - "code": "content_too_large", - "message": "Fixture session.compact error: content_too_large", + "code": "invalid_idempotency_key", + "message": "Fixture session.interruptAndDeliver error: invalid_idempotency_key", "retryable": false } }, { "kind": "error", - "id": 1801, - "method": "session.queue.enqueue", + "id": 1712, + "method": "session.interruptAndDeliver", "error": { - "code": "daemon_stopping", - "message": "Fixture session.queue.enqueue error: daemon_stopping", + "code": "idempotency_conflict", + "message": "Fixture session.interruptAndDeliver error: idempotency_conflict", "retryable": false } }, { "kind": "error", - "id": 1802, - "method": "session.queue.enqueue", + "id": 1713, + "method": "session.interruptAndDeliver", "error": { - "code": "bad_request", - "message": "Fixture session.queue.enqueue error: bad_request", + "code": "blob_not_owned", + "message": "Fixture session.interruptAndDeliver error: blob_not_owned", "retryable": false } }, { "kind": "error", - "id": 1803, - "method": "session.queue.enqueue", + "id": 1714, + "method": "session.interruptAndDeliver", "error": { - "code": "not_initialized", - "message": "Fixture session.queue.enqueue error: not_initialized", + "code": "blob_missing", + "message": "Fixture session.interruptAndDeliver error: blob_missing", "retryable": false } }, { "kind": "error", - "id": 1804, - "method": "session.queue.enqueue", + "id": 1715, + "method": "session.interruptAndDeliver", "error": { - "code": "unsupported_capability", - "message": "Fixture session.queue.enqueue error: unsupported_capability", + "code": "blob_corrupt", + "message": "Fixture session.interruptAndDeliver error: blob_corrupt", "retryable": false } }, { "kind": "error", - "id": 1805, - "method": "session.queue.enqueue", + "id": 1716, + "method": "session.interruptAndDeliver", "error": { - "code": "rate_limited", - "message": "Fixture session.queue.enqueue error: rate_limited", - "retryable": true + "code": "content_too_large", + "message": "Fixture session.interruptAndDeliver error: content_too_large", + "retryable": false } }, { "kind": "error", - "id": 1806, - "method": "session.queue.enqueue", + "id": 1801, + "method": "session.compact", "error": { - "code": "internal_error", - "message": "Fixture session.queue.enqueue error: internal_error", + "code": "daemon_stopping", + "message": "Fixture session.compact error: daemon_stopping", "retryable": false } }, { "kind": "error", - "id": 1807, - "method": "session.queue.enqueue", + "id": 1802, + "method": "session.compact", "error": { - "code": "cancelled", - "message": "Fixture session.queue.enqueue error: cancelled", + "code": "bad_request", + "message": "Fixture session.compact error: bad_request", "retryable": false } }, { "kind": "error", - "id": 1808, - "method": "session.queue.enqueue", + "id": 1803, + "method": "session.compact", "error": { - "code": "unknown_session", - "message": "Fixture session.queue.enqueue error: unknown_session", + "code": "not_initialized", + "message": "Fixture session.compact error: not_initialized", "retryable": false } }, { "kind": "error", - "id": 1809, - "method": "session.queue.enqueue", + "id": 1804, + "method": "session.compact", "error": { - "code": "event_migration_required", - "message": "Fixture session.queue.enqueue error: event_migration_required", + "code": "unsupported_capability", + "message": "Fixture session.compact error: unsupported_capability", "retryable": false } }, { "kind": "error", - "id": 1810, - "method": "session.queue.enqueue", + "id": 1805, + "method": "session.compact", "error": { - "code": "invalid_idempotency_key", - "message": "Fixture session.queue.enqueue error: invalid_idempotency_key", + "code": "rate_limited", + "message": "Fixture session.compact error: rate_limited", + "retryable": true + } + }, + { + "kind": "error", + "id": 1806, + "method": "session.compact", + "error": { + "code": "internal_error", + "message": "Fixture session.compact error: internal_error", "retryable": false } }, { "kind": "error", - "id": 1811, - "method": "session.queue.enqueue", + "id": 1807, + "method": "session.compact", "error": { - "code": "idempotency_conflict", - "message": "Fixture session.queue.enqueue error: idempotency_conflict", + "code": "cancelled", + "message": "Fixture session.compact error: cancelled", "retryable": false } }, { "kind": "error", - "id": 1812, - "method": "session.queue.enqueue", + "id": 1808, + "method": "session.compact", "error": { - "code": "blob_not_owned", - "message": "Fixture session.queue.enqueue error: blob_not_owned", + "code": "unknown_session", + "message": "Fixture session.compact error: unknown_session", "retryable": false } }, { "kind": "error", - "id": 1813, - "method": "session.queue.enqueue", + "id": 1809, + "method": "session.compact", "error": { - "code": "blob_missing", - "message": "Fixture session.queue.enqueue error: blob_missing", + "code": "event_migration_required", + "message": "Fixture session.compact error: event_migration_required", "retryable": false } }, { "kind": "error", - "id": 1814, - "method": "session.queue.enqueue", + "id": 1810, + "method": "session.compact", "error": { - "code": "blob_corrupt", - "message": "Fixture session.queue.enqueue error: blob_corrupt", + "code": "operation_active", + "message": "Fixture session.compact error: operation_active", "retryable": false } }, { "kind": "error", - "id": 1815, - "method": "session.queue.enqueue", + "id": 1811, + "method": "session.compact", "error": { "code": "content_too_large", - "message": "Fixture session.queue.enqueue error: content_too_large", + "message": "Fixture session.compact error: content_too_large", "retryable": false } }, { "kind": "error", "id": 1901, - "method": "session.queue.requeue", + "method": "session.queue.enqueue", "error": { "code": "daemon_stopping", - "message": "Fixture session.queue.requeue error: daemon_stopping", + "message": "Fixture session.queue.enqueue error: daemon_stopping", "retryable": false } }, { "kind": "error", "id": 1902, - "method": "session.queue.requeue", + "method": "session.queue.enqueue", "error": { "code": "bad_request", - "message": "Fixture session.queue.requeue error: bad_request", + "message": "Fixture session.queue.enqueue error: bad_request", "retryable": false } }, { "kind": "error", "id": 1903, - "method": "session.queue.requeue", + "method": "session.queue.enqueue", "error": { "code": "not_initialized", - "message": "Fixture session.queue.requeue error: not_initialized", + "message": "Fixture session.queue.enqueue error: not_initialized", "retryable": false } }, { "kind": "error", "id": 1904, - "method": "session.queue.requeue", + "method": "session.queue.enqueue", "error": { "code": "unsupported_capability", - "message": "Fixture session.queue.requeue error: unsupported_capability", + "message": "Fixture session.queue.enqueue error: unsupported_capability", "retryable": false } }, { "kind": "error", "id": 1905, - "method": "session.queue.requeue", + "method": "session.queue.enqueue", "error": { "code": "rate_limited", - "message": "Fixture session.queue.requeue error: rate_limited", + "message": "Fixture session.queue.enqueue error: rate_limited", "retryable": true } }, { "kind": "error", "id": 1906, - "method": "session.queue.requeue", + "method": "session.queue.enqueue", "error": { "code": "internal_error", - "message": "Fixture session.queue.requeue error: internal_error", + "message": "Fixture session.queue.enqueue error: internal_error", "retryable": false } }, { "kind": "error", "id": 1907, + "method": "session.queue.enqueue", + "error": { + "code": "cancelled", + "message": "Fixture session.queue.enqueue error: cancelled", + "retryable": false + } + }, + { + "kind": "error", + "id": 1908, + "method": "session.queue.enqueue", + "error": { + "code": "unknown_session", + "message": "Fixture session.queue.enqueue error: unknown_session", + "retryable": false + } + }, + { + "kind": "error", + "id": 1909, + "method": "session.queue.enqueue", + "error": { + "code": "event_migration_required", + "message": "Fixture session.queue.enqueue error: event_migration_required", + "retryable": false + } + }, + { + "kind": "error", + "id": 1910, + "method": "session.queue.enqueue", + "error": { + "code": "invalid_idempotency_key", + "message": "Fixture session.queue.enqueue error: invalid_idempotency_key", + "retryable": false + } + }, + { + "kind": "error", + "id": 1911, + "method": "session.queue.enqueue", + "error": { + "code": "idempotency_conflict", + "message": "Fixture session.queue.enqueue error: idempotency_conflict", + "retryable": false + } + }, + { + "kind": "error", + "id": 1912, + "method": "session.queue.enqueue", + "error": { + "code": "blob_not_owned", + "message": "Fixture session.queue.enqueue error: blob_not_owned", + "retryable": false + } + }, + { + "kind": "error", + "id": 1913, + "method": "session.queue.enqueue", + "error": { + "code": "blob_missing", + "message": "Fixture session.queue.enqueue error: blob_missing", + "retryable": false + } + }, + { + "kind": "error", + "id": 1914, + "method": "session.queue.enqueue", + "error": { + "code": "blob_corrupt", + "message": "Fixture session.queue.enqueue error: blob_corrupt", + "retryable": false + } + }, + { + "kind": "error", + "id": 1915, + "method": "session.queue.enqueue", + "error": { + "code": "content_too_large", + "message": "Fixture session.queue.enqueue error: content_too_large", + "retryable": false + } + }, + { + "kind": "error", + "id": 2001, + "method": "session.queue.requeue", + "error": { + "code": "daemon_stopping", + "message": "Fixture session.queue.requeue error: daemon_stopping", + "retryable": false + } + }, + { + "kind": "error", + "id": 2002, + "method": "session.queue.requeue", + "error": { + "code": "bad_request", + "message": "Fixture session.queue.requeue error: bad_request", + "retryable": false + } + }, + { + "kind": "error", + "id": 2003, + "method": "session.queue.requeue", + "error": { + "code": "not_initialized", + "message": "Fixture session.queue.requeue error: not_initialized", + "retryable": false + } + }, + { + "kind": "error", + "id": 2004, + "method": "session.queue.requeue", + "error": { + "code": "unsupported_capability", + "message": "Fixture session.queue.requeue error: unsupported_capability", + "retryable": false + } + }, + { + "kind": "error", + "id": 2005, + "method": "session.queue.requeue", + "error": { + "code": "rate_limited", + "message": "Fixture session.queue.requeue error: rate_limited", + "retryable": true + } + }, + { + "kind": "error", + "id": 2006, + "method": "session.queue.requeue", + "error": { + "code": "internal_error", + "message": "Fixture session.queue.requeue error: internal_error", + "retryable": false + } + }, + { + "kind": "error", + "id": 2007, "method": "session.queue.requeue", "error": { "code": "cancelled", @@ -3805,7 +3990,7 @@ }, { "kind": "error", - "id": 1908, + "id": 2008, "method": "session.queue.requeue", "error": { "code": "unknown_session", @@ -3815,7 +4000,7 @@ }, { "kind": "error", - "id": 1909, + "id": 2009, "method": "session.queue.requeue", "error": { "code": "event_migration_required", @@ -3825,7 +4010,7 @@ }, { "kind": "error", - "id": 1910, + "id": 2010, "method": "session.queue.requeue", "error": { "code": "unknown_queue_item", @@ -3835,7 +4020,7 @@ }, { "kind": "error", - "id": 1911, + "id": 2011, "method": "session.queue.requeue", "error": { "code": "queue_not_paused", @@ -3845,7 +4030,7 @@ }, { "kind": "error", - "id": 1912, + "id": 2012, "method": "session.queue.requeue", "error": { "code": "invalid_idempotency_key", @@ -3855,7 +4040,7 @@ }, { "kind": "error", - "id": 1913, + "id": 2013, "method": "session.queue.requeue", "error": { "code": "idempotency_conflict", @@ -3865,7 +4050,7 @@ }, { "kind": "error", - "id": 1914, + "id": 2014, "method": "session.queue.requeue", "error": { "code": "content_too_large", @@ -3875,7 +4060,7 @@ }, { "kind": "error", - "id": 2001, + "id": 2101, "method": "session.shell", "error": { "code": "daemon_stopping", @@ -3885,7 +4070,7 @@ }, { "kind": "error", - "id": 2002, + "id": 2102, "method": "session.shell", "error": { "code": "bad_request", @@ -3895,7 +4080,7 @@ }, { "kind": "error", - "id": 2003, + "id": 2103, "method": "session.shell", "error": { "code": "not_initialized", @@ -3905,7 +4090,7 @@ }, { "kind": "error", - "id": 2004, + "id": 2104, "method": "session.shell", "error": { "code": "unsupported_capability", @@ -3915,7 +4100,7 @@ }, { "kind": "error", - "id": 2005, + "id": 2105, "method": "session.shell", "error": { "code": "rate_limited", @@ -3925,7 +4110,7 @@ }, { "kind": "error", - "id": 2006, + "id": 2106, "method": "session.shell", "error": { "code": "internal_error", @@ -3935,7 +4120,7 @@ }, { "kind": "error", - "id": 2007, + "id": 2107, "method": "session.shell", "error": { "code": "cancelled", @@ -3945,7 +4130,7 @@ }, { "kind": "error", - "id": 2008, + "id": 2108, "method": "session.shell", "error": { "code": "unknown_session", @@ -3955,7 +4140,7 @@ }, { "kind": "error", - "id": 2009, + "id": 2109, "method": "session.shell", "error": { "code": "event_migration_required", @@ -3965,7 +4150,7 @@ }, { "kind": "error", - "id": 2010, + "id": 2110, "method": "session.shell", "error": { "code": "operation_active", @@ -3975,7 +4160,7 @@ }, { "kind": "error", - "id": 2011, + "id": 2111, "method": "session.shell", "error": { "code": "idempotency_conflict", @@ -3985,7 +4170,7 @@ }, { "kind": "error", - "id": 2012, + "id": 2112, "method": "session.shell", "error": { "code": "content_too_large", @@ -3995,7 +4180,7 @@ }, { "kind": "error", - "id": 2101, + "id": 2201, "method": "session.interrupt", "error": { "code": "daemon_stopping", @@ -4005,7 +4190,7 @@ }, { "kind": "error", - "id": 2102, + "id": 2202, "method": "session.interrupt", "error": { "code": "bad_request", @@ -4015,7 +4200,7 @@ }, { "kind": "error", - "id": 2103, + "id": 2203, "method": "session.interrupt", "error": { "code": "not_initialized", @@ -4025,7 +4210,7 @@ }, { "kind": "error", - "id": 2104, + "id": 2204, "method": "session.interrupt", "error": { "code": "unsupported_capability", @@ -4035,7 +4220,7 @@ }, { "kind": "error", - "id": 2105, + "id": 2205, "method": "session.interrupt", "error": { "code": "rate_limited", @@ -4045,7 +4230,7 @@ }, { "kind": "error", - "id": 2106, + "id": 2206, "method": "session.interrupt", "error": { "code": "internal_error", @@ -4055,7 +4240,7 @@ }, { "kind": "error", - "id": 2107, + "id": 2207, "method": "session.interrupt", "error": { "code": "cancelled", @@ -4065,7 +4250,7 @@ }, { "kind": "error", - "id": 2108, + "id": 2208, "method": "session.interrupt", "error": { "code": "unknown_session", @@ -4075,7 +4260,7 @@ }, { "kind": "error", - "id": 2109, + "id": 2209, "method": "session.interrupt", "error": { "code": "event_migration_required", @@ -4085,7 +4270,7 @@ }, { "kind": "error", - "id": 2110, + "id": 2210, "method": "session.interrupt", "error": { "code": "invalid_idempotency_key", @@ -4095,7 +4280,7 @@ }, { "kind": "error", - "id": 2111, + "id": 2211, "method": "session.interrupt", "error": { "code": "idempotency_conflict", @@ -4105,7 +4290,7 @@ }, { "kind": "error", - "id": 2201, + "id": 2301, "method": "session.reload", "error": { "code": "daemon_stopping", @@ -4115,7 +4300,7 @@ }, { "kind": "error", - "id": 2202, + "id": 2302, "method": "session.reload", "error": { "code": "bad_request", @@ -4125,7 +4310,7 @@ }, { "kind": "error", - "id": 2203, + "id": 2303, "method": "session.reload", "error": { "code": "not_initialized", @@ -4135,7 +4320,7 @@ }, { "kind": "error", - "id": 2204, + "id": 2304, "method": "session.reload", "error": { "code": "unsupported_capability", @@ -4145,7 +4330,7 @@ }, { "kind": "error", - "id": 2205, + "id": 2305, "method": "session.reload", "error": { "code": "rate_limited", @@ -4155,7 +4340,7 @@ }, { "kind": "error", - "id": 2206, + "id": 2306, "method": "session.reload", "error": { "code": "internal_error", @@ -4165,7 +4350,7 @@ }, { "kind": "error", - "id": 2207, + "id": 2307, "method": "session.reload", "error": { "code": "cancelled", @@ -4175,7 +4360,7 @@ }, { "kind": "error", - "id": 2208, + "id": 2308, "method": "session.reload", "error": { "code": "unknown_session", @@ -4185,7 +4370,7 @@ }, { "kind": "error", - "id": 2209, + "id": 2309, "method": "session.reload", "error": { "code": "event_migration_required", @@ -4195,7 +4380,7 @@ }, { "kind": "error", - "id": 2210, + "id": 2310, "method": "session.reload", "error": { "code": "corrupt_session", @@ -4205,7 +4390,7 @@ }, { "kind": "error", - "id": 2211, + "id": 2311, "method": "session.reload", "error": { "code": "operation_active", @@ -4215,7 +4400,7 @@ }, { "kind": "error", - "id": 2212, + "id": 2312, "method": "session.reload", "error": { "code": "invalid_idempotency_key", @@ -4225,7 +4410,7 @@ }, { "kind": "error", - "id": 2213, + "id": 2313, "method": "session.reload", "error": { "code": "idempotency_conflict", @@ -4235,7 +4420,7 @@ }, { "kind": "error", - "id": 2214, + "id": 2314, "method": "session.reload", "error": { "code": "content_too_large", @@ -4245,7 +4430,7 @@ }, { "kind": "error", - "id": 2301, + "id": 2401, "method": "session.configure", "error": { "code": "daemon_stopping", @@ -4255,7 +4440,7 @@ }, { "kind": "error", - "id": 2302, + "id": 2402, "method": "session.configure", "error": { "code": "bad_request", @@ -4265,7 +4450,7 @@ }, { "kind": "error", - "id": 2303, + "id": 2403, "method": "session.configure", "error": { "code": "not_initialized", @@ -4275,7 +4460,7 @@ }, { "kind": "error", - "id": 2304, + "id": 2404, "method": "session.configure", "error": { "code": "unsupported_capability", @@ -4285,7 +4470,7 @@ }, { "kind": "error", - "id": 2305, + "id": 2405, "method": "session.configure", "error": { "code": "rate_limited", @@ -4295,7 +4480,7 @@ }, { "kind": "error", - "id": 2306, + "id": 2406, "method": "session.configure", "error": { "code": "internal_error", @@ -4305,7 +4490,7 @@ }, { "kind": "error", - "id": 2307, + "id": 2407, "method": "session.configure", "error": { "code": "cancelled", @@ -4315,7 +4500,7 @@ }, { "kind": "error", - "id": 2308, + "id": 2408, "method": "session.configure", "error": { "code": "unknown_session", @@ -4325,7 +4510,7 @@ }, { "kind": "error", - "id": 2309, + "id": 2409, "method": "session.configure", "error": { "code": "event_migration_required", @@ -4335,7 +4520,7 @@ }, { "kind": "error", - "id": 2310, + "id": 2410, "method": "session.configure", "error": { "code": "corrupt_session", @@ -4345,7 +4530,7 @@ }, { "kind": "error", - "id": 2311, + "id": 2411, "method": "session.configure", "error": { "code": "operation_active", @@ -4355,7 +4540,7 @@ }, { "kind": "error", - "id": 2312, + "id": 2412, "method": "session.configure", "error": { "code": "invalid_idempotency_key", @@ -4365,7 +4550,7 @@ }, { "kind": "error", - "id": 2313, + "id": 2413, "method": "session.configure", "error": { "code": "idempotency_conflict", @@ -4375,7 +4560,7 @@ }, { "kind": "error", - "id": 2314, + "id": 2414, "method": "session.configure", "error": { "code": "content_too_large", @@ -4385,7 +4570,7 @@ }, { "kind": "error", - "id": 2401, + "id": 2501, "method": "session.interaction.respond", "error": { "code": "daemon_stopping", @@ -4395,7 +4580,7 @@ }, { "kind": "error", - "id": 2402, + "id": 2502, "method": "session.interaction.respond", "error": { "code": "bad_request", @@ -4405,7 +4590,7 @@ }, { "kind": "error", - "id": 2403, + "id": 2503, "method": "session.interaction.respond", "error": { "code": "not_initialized", @@ -4415,7 +4600,7 @@ }, { "kind": "error", - "id": 2404, + "id": 2504, "method": "session.interaction.respond", "error": { "code": "unsupported_capability", @@ -4425,7 +4610,7 @@ }, { "kind": "error", - "id": 2405, + "id": 2505, "method": "session.interaction.respond", "error": { "code": "rate_limited", @@ -4435,7 +4620,7 @@ }, { "kind": "error", - "id": 2406, + "id": 2506, "method": "session.interaction.respond", "error": { "code": "internal_error", @@ -4445,7 +4630,7 @@ }, { "kind": "error", - "id": 2407, + "id": 2507, "method": "session.interaction.respond", "error": { "code": "cancelled", @@ -4455,7 +4640,7 @@ }, { "kind": "error", - "id": 2408, + "id": 2508, "method": "session.interaction.respond", "error": { "code": "unknown_session", @@ -4465,7 +4650,7 @@ }, { "kind": "error", - "id": 2409, + "id": 2509, "method": "session.interaction.respond", "error": { "code": "event_migration_required", @@ -4475,7 +4660,7 @@ }, { "kind": "error", - "id": 2410, + "id": 2510, "method": "session.interaction.respond", "error": { "code": "unknown_interaction", @@ -4485,7 +4670,7 @@ }, { "kind": "error", - "id": 2411, + "id": 2511, "method": "session.interaction.respond", "error": { "code": "interaction_already_resolved", @@ -4495,7 +4680,7 @@ }, { "kind": "error", - "id": 2412, + "id": 2512, "method": "session.interaction.respond", "error": { "code": "invalid_idempotency_key", @@ -4505,7 +4690,7 @@ }, { "kind": "error", - "id": 2413, + "id": 2513, "method": "session.interaction.respond", "error": { "code": "idempotency_conflict", @@ -4515,7 +4700,7 @@ }, { "kind": "error", - "id": 2414, + "id": 2514, "method": "session.interaction.respond", "error": { "code": "content_too_large", @@ -4525,7 +4710,7 @@ }, { "kind": "error", - "id": 2501, + "id": 2601, "method": "session.subscribe", "error": { "code": "daemon_stopping", @@ -4535,7 +4720,7 @@ }, { "kind": "error", - "id": 2502, + "id": 2602, "method": "session.subscribe", "error": { "code": "bad_request", @@ -4545,7 +4730,7 @@ }, { "kind": "error", - "id": 2503, + "id": 2603, "method": "session.subscribe", "error": { "code": "not_initialized", @@ -4555,7 +4740,7 @@ }, { "kind": "error", - "id": 2504, + "id": 2604, "method": "session.subscribe", "error": { "code": "unsupported_capability", @@ -4565,7 +4750,7 @@ }, { "kind": "error", - "id": 2505, + "id": 2605, "method": "session.subscribe", "error": { "code": "rate_limited", @@ -4575,7 +4760,7 @@ }, { "kind": "error", - "id": 2506, + "id": 2606, "method": "session.subscribe", "error": { "code": "internal_error", @@ -4585,7 +4770,7 @@ }, { "kind": "error", - "id": 2507, + "id": 2607, "method": "session.subscribe", "error": { "code": "cancelled", @@ -4595,7 +4780,7 @@ }, { "kind": "error", - "id": 2508, + "id": 2608, "method": "session.subscribe", "error": { "code": "unknown_session", @@ -4605,7 +4790,7 @@ }, { "kind": "error", - "id": 2509, + "id": 2609, "method": "session.subscribe", "error": { "code": "event_migration_required", @@ -4615,7 +4800,7 @@ }, { "kind": "error", - "id": 2510, + "id": 2610, "method": "session.subscribe", "error": { "code": "snapshot_required", @@ -4625,7 +4810,7 @@ }, { "kind": "error", - "id": 2601, + "id": 2701, "method": "session.workspace.list", "error": { "code": "daemon_stopping", @@ -4635,7 +4820,7 @@ }, { "kind": "error", - "id": 2602, + "id": 2702, "method": "session.workspace.list", "error": { "code": "bad_request", @@ -4645,7 +4830,7 @@ }, { "kind": "error", - "id": 2603, + "id": 2703, "method": "session.workspace.list", "error": { "code": "not_initialized", @@ -4655,7 +4840,7 @@ }, { "kind": "error", - "id": 2604, + "id": 2704, "method": "session.workspace.list", "error": { "code": "unsupported_capability", @@ -4665,7 +4850,7 @@ }, { "kind": "error", - "id": 2605, + "id": 2705, "method": "session.workspace.list", "error": { "code": "rate_limited", @@ -4675,7 +4860,7 @@ }, { "kind": "error", - "id": 2606, + "id": 2706, "method": "session.workspace.list", "error": { "code": "internal_error", @@ -4685,7 +4870,7 @@ }, { "kind": "error", - "id": 2607, + "id": 2707, "method": "session.workspace.list", "error": { "code": "cancelled", @@ -4695,7 +4880,7 @@ }, { "kind": "error", - "id": 2608, + "id": 2708, "method": "session.workspace.list", "error": { "code": "unknown_session", @@ -4705,7 +4890,7 @@ }, { "kind": "error", - "id": 2609, + "id": 2709, "method": "session.workspace.list", "error": { "code": "event_migration_required", @@ -4715,7 +4900,7 @@ }, { "kind": "error", - "id": 2610, + "id": 2710, "method": "session.workspace.list", "error": { "code": "workspace_unavailable", @@ -4725,7 +4910,7 @@ }, { "kind": "error", - "id": 2611, + "id": 2711, "method": "session.workspace.list", "error": { "code": "workspace_changed", @@ -4735,7 +4920,7 @@ }, { "kind": "error", - "id": 2612, + "id": 2712, "method": "session.workspace.list", "error": { "code": "invalid_path", @@ -4745,7 +4930,7 @@ }, { "kind": "error", - "id": 2613, + "id": 2713, "method": "session.workspace.list", "error": { "code": "path_denied", @@ -4755,7 +4940,7 @@ }, { "kind": "error", - "id": 2614, + "id": 2714, "method": "session.workspace.list", "error": { "code": "symlink_escape", @@ -4765,7 +4950,7 @@ }, { "kind": "error", - "id": 2615, + "id": 2715, "method": "session.workspace.list", "error": { "code": "not_found", @@ -4775,7 +4960,7 @@ }, { "kind": "error", - "id": 2616, + "id": 2716, "method": "session.workspace.list", "error": { "code": "unsupported_file_type", @@ -4785,7 +4970,7 @@ }, { "kind": "error", - "id": 2617, + "id": 2717, "method": "session.workspace.list", "error": { "code": "unsupported_filename_encoding", @@ -4795,7 +4980,7 @@ }, { "kind": "error", - "id": 2701, + "id": 2801, "method": "session.workspace.read", "error": { "code": "daemon_stopping", @@ -4805,7 +4990,7 @@ }, { "kind": "error", - "id": 2702, + "id": 2802, "method": "session.workspace.read", "error": { "code": "bad_request", @@ -4815,7 +5000,7 @@ }, { "kind": "error", - "id": 2703, + "id": 2803, "method": "session.workspace.read", "error": { "code": "not_initialized", @@ -4825,7 +5010,7 @@ }, { "kind": "error", - "id": 2704, + "id": 2804, "method": "session.workspace.read", "error": { "code": "unsupported_capability", @@ -4835,7 +5020,7 @@ }, { "kind": "error", - "id": 2705, + "id": 2805, "method": "session.workspace.read", "error": { "code": "rate_limited", @@ -4845,7 +5030,7 @@ }, { "kind": "error", - "id": 2706, + "id": 2806, "method": "session.workspace.read", "error": { "code": "internal_error", @@ -4855,7 +5040,7 @@ }, { "kind": "error", - "id": 2707, + "id": 2807, "method": "session.workspace.read", "error": { "code": "cancelled", @@ -4865,7 +5050,7 @@ }, { "kind": "error", - "id": 2708, + "id": 2808, "method": "session.workspace.read", "error": { "code": "unknown_session", @@ -4875,7 +5060,7 @@ }, { "kind": "error", - "id": 2709, + "id": 2809, "method": "session.workspace.read", "error": { "code": "event_migration_required", @@ -4885,7 +5070,7 @@ }, { "kind": "error", - "id": 2710, + "id": 2810, "method": "session.workspace.read", "error": { "code": "workspace_unavailable", @@ -4895,7 +5080,7 @@ }, { "kind": "error", - "id": 2711, + "id": 2811, "method": "session.workspace.read", "error": { "code": "workspace_changed", @@ -4905,7 +5090,7 @@ }, { "kind": "error", - "id": 2712, + "id": 2812, "method": "session.workspace.read", "error": { "code": "invalid_path", @@ -4915,7 +5100,7 @@ }, { "kind": "error", - "id": 2713, + "id": 2813, "method": "session.workspace.read", "error": { "code": "path_denied", @@ -4925,7 +5110,7 @@ }, { "kind": "error", - "id": 2714, + "id": 2814, "method": "session.workspace.read", "error": { "code": "symlink_escape", @@ -4935,7 +5120,7 @@ }, { "kind": "error", - "id": 2715, + "id": 2815, "method": "session.workspace.read", "error": { "code": "not_found", @@ -4945,7 +5130,7 @@ }, { "kind": "error", - "id": 2716, + "id": 2816, "method": "session.workspace.read", "error": { "code": "not_a_file", @@ -4955,7 +5140,7 @@ }, { "kind": "error", - "id": 2717, + "id": 2817, "method": "session.workspace.read", "error": { "code": "unsupported_file_type", @@ -4965,7 +5150,7 @@ }, { "kind": "error", - "id": 2718, + "id": 2818, "method": "session.workspace.read", "error": { "code": "binary_file", @@ -4975,7 +5160,7 @@ }, { "kind": "error", - "id": 2719, + "id": 2819, "method": "session.workspace.read", "error": { "code": "invalid_encoding", @@ -4985,7 +5170,7 @@ }, { "kind": "error", - "id": 2720, + "id": 2820, "method": "session.workspace.read", "error": { "code": "content_too_large", @@ -4995,7 +5180,7 @@ }, { "kind": "error", - "id": 2801, + "id": 2901, "method": "session.workspace.status", "error": { "code": "daemon_stopping", @@ -5005,7 +5190,7 @@ }, { "kind": "error", - "id": 2802, + "id": 2902, "method": "session.workspace.status", "error": { "code": "bad_request", @@ -5015,7 +5200,7 @@ }, { "kind": "error", - "id": 2803, + "id": 2903, "method": "session.workspace.status", "error": { "code": "not_initialized", @@ -5025,7 +5210,7 @@ }, { "kind": "error", - "id": 2804, + "id": 2904, "method": "session.workspace.status", "error": { "code": "unsupported_capability", @@ -5035,7 +5220,7 @@ }, { "kind": "error", - "id": 2805, + "id": 2905, "method": "session.workspace.status", "error": { "code": "rate_limited", @@ -5045,7 +5230,7 @@ }, { "kind": "error", - "id": 2806, + "id": 2906, "method": "session.workspace.status", "error": { "code": "internal_error", @@ -5055,7 +5240,7 @@ }, { "kind": "error", - "id": 2807, + "id": 2907, "method": "session.workspace.status", "error": { "code": "cancelled", @@ -5065,7 +5250,7 @@ }, { "kind": "error", - "id": 2808, + "id": 2908, "method": "session.workspace.status", "error": { "code": "unknown_session", @@ -5075,7 +5260,7 @@ }, { "kind": "error", - "id": 2809, + "id": 2909, "method": "session.workspace.status", "error": { "code": "event_migration_required", @@ -5085,7 +5270,7 @@ }, { "kind": "error", - "id": 2810, + "id": 2910, "method": "session.workspace.status", "error": { "code": "workspace_unavailable", @@ -5095,7 +5280,7 @@ }, { "kind": "error", - "id": 2811, + "id": 2911, "method": "session.workspace.status", "error": { "code": "workspace_changed", @@ -5105,7 +5290,7 @@ }, { "kind": "error", - "id": 2812, + "id": 2912, "method": "session.workspace.status", "error": { "code": "not_git_repository", @@ -5115,7 +5300,7 @@ }, { "kind": "error", - "id": 2813, + "id": 2913, "method": "session.workspace.status", "error": { "code": "git_unavailable", @@ -5125,7 +5310,7 @@ }, { "kind": "error", - "id": 2814, + "id": 2914, "method": "session.workspace.status", "error": { "code": "git_timeout", @@ -5135,7 +5320,7 @@ }, { "kind": "error", - "id": 2815, + "id": 2915, "method": "session.workspace.status", "error": { "code": "git_output_too_large", @@ -5145,7 +5330,7 @@ }, { "kind": "error", - "id": 2816, + "id": 2916, "method": "session.workspace.status", "error": { "code": "unsupported_git_state", @@ -5155,7 +5340,7 @@ }, { "kind": "error", - "id": 2817, + "id": 2917, "method": "session.workspace.status", "error": { "code": "unsupported_filename_encoding", @@ -5165,7 +5350,7 @@ }, { "kind": "error", - "id": 2818, + "id": 2918, "method": "session.workspace.status", "error": { "code": "checkpoint_unavailable", @@ -5175,7 +5360,7 @@ }, { "kind": "error", - "id": 2819, + "id": 2919, "method": "session.workspace.status", "error": { "code": "checkpoint_too_large", @@ -5185,7 +5370,7 @@ }, { "kind": "error", - "id": 2820, + "id": 2920, "method": "session.workspace.status", "error": { "code": "checkpoint_corrupt", @@ -5195,7 +5380,7 @@ }, { "kind": "error", - "id": 2821, + "id": 2921, "method": "session.workspace.status", "error": { "code": "path_denied", @@ -5205,7 +5390,7 @@ }, { "kind": "error", - "id": 2901, + "id": 3001, "method": "session.workspace.diff", "error": { "code": "daemon_stopping", @@ -5215,7 +5400,7 @@ }, { "kind": "error", - "id": 2902, + "id": 3002, "method": "session.workspace.diff", "error": { "code": "bad_request", @@ -5225,7 +5410,7 @@ }, { "kind": "error", - "id": 2903, + "id": 3003, "method": "session.workspace.diff", "error": { "code": "not_initialized", @@ -5235,7 +5420,7 @@ }, { "kind": "error", - "id": 2904, + "id": 3004, "method": "session.workspace.diff", "error": { "code": "unsupported_capability", @@ -5245,7 +5430,7 @@ }, { "kind": "error", - "id": 2905, + "id": 3005, "method": "session.workspace.diff", "error": { "code": "rate_limited", @@ -5255,7 +5440,7 @@ }, { "kind": "error", - "id": 2906, + "id": 3006, "method": "session.workspace.diff", "error": { "code": "internal_error", @@ -5265,7 +5450,7 @@ }, { "kind": "error", - "id": 2907, + "id": 3007, "method": "session.workspace.diff", "error": { "code": "cancelled", @@ -5275,7 +5460,7 @@ }, { "kind": "error", - "id": 2908, + "id": 3008, "method": "session.workspace.diff", "error": { "code": "unknown_session", @@ -5285,7 +5470,7 @@ }, { "kind": "error", - "id": 2909, + "id": 3009, "method": "session.workspace.diff", "error": { "code": "event_migration_required", @@ -5295,7 +5480,7 @@ }, { "kind": "error", - "id": 2910, + "id": 3010, "method": "session.workspace.diff", "error": { "code": "workspace_unavailable", @@ -5305,7 +5490,7 @@ }, { "kind": "error", - "id": 2911, + "id": 3011, "method": "session.workspace.diff", "error": { "code": "workspace_changed", @@ -5315,7 +5500,7 @@ }, { "kind": "error", - "id": 2912, + "id": 3012, "method": "session.workspace.diff", "error": { "code": "not_git_repository", @@ -5325,7 +5510,7 @@ }, { "kind": "error", - "id": 2913, + "id": 3013, "method": "session.workspace.diff", "error": { "code": "git_unavailable", @@ -5335,7 +5520,7 @@ }, { "kind": "error", - "id": 2914, + "id": 3014, "method": "session.workspace.diff", "error": { "code": "git_timeout", @@ -5345,7 +5530,7 @@ }, { "kind": "error", - "id": 2915, + "id": 3015, "method": "session.workspace.diff", "error": { "code": "git_output_too_large", @@ -5355,7 +5540,7 @@ }, { "kind": "error", - "id": 2916, + "id": 3016, "method": "session.workspace.diff", "error": { "code": "unsupported_git_state", @@ -5365,7 +5550,7 @@ }, { "kind": "error", - "id": 2917, + "id": 3017, "method": "session.workspace.diff", "error": { "code": "unsupported_filename_encoding", @@ -5375,7 +5560,7 @@ }, { "kind": "error", - "id": 2918, + "id": 3018, "method": "session.workspace.diff", "error": { "code": "checkpoint_unavailable", @@ -5385,7 +5570,7 @@ }, { "kind": "error", - "id": 2919, + "id": 3019, "method": "session.workspace.diff", "error": { "code": "checkpoint_too_large", @@ -5395,7 +5580,7 @@ }, { "kind": "error", - "id": 2920, + "id": 3020, "method": "session.workspace.diff", "error": { "code": "checkpoint_corrupt", @@ -5405,7 +5590,7 @@ }, { "kind": "error", - "id": 2921, + "id": 3021, "method": "session.workspace.diff", "error": { "code": "path_denied", @@ -5415,7 +5600,7 @@ }, { "kind": "error", - "id": 2922, + "id": 3022, "method": "session.workspace.diff", "error": { "code": "repository_changed", @@ -5425,7 +5610,7 @@ }, { "kind": "error", - "id": 3001, + "id": 3101, "method": "session.workspace.checkpoint", "error": { "code": "daemon_stopping", @@ -5435,7 +5620,7 @@ }, { "kind": "error", - "id": 3002, + "id": 3102, "method": "session.workspace.checkpoint", "error": { "code": "bad_request", @@ -5445,7 +5630,7 @@ }, { "kind": "error", - "id": 3003, + "id": 3103, "method": "session.workspace.checkpoint", "error": { "code": "not_initialized", @@ -5455,7 +5640,7 @@ }, { "kind": "error", - "id": 3004, + "id": 3104, "method": "session.workspace.checkpoint", "error": { "code": "unsupported_capability", @@ -5465,7 +5650,7 @@ }, { "kind": "error", - "id": 3005, + "id": 3105, "method": "session.workspace.checkpoint", "error": { "code": "rate_limited", @@ -5475,7 +5660,7 @@ }, { "kind": "error", - "id": 3006, + "id": 3106, "method": "session.workspace.checkpoint", "error": { "code": "internal_error", @@ -5485,7 +5670,7 @@ }, { "kind": "error", - "id": 3007, + "id": 3107, "method": "session.workspace.checkpoint", "error": { "code": "cancelled", @@ -5495,7 +5680,7 @@ }, { "kind": "error", - "id": 3008, + "id": 3108, "method": "session.workspace.checkpoint", "error": { "code": "unknown_session", @@ -5505,7 +5690,7 @@ }, { "kind": "error", - "id": 3009, + "id": 3109, "method": "session.workspace.checkpoint", "error": { "code": "event_migration_required", @@ -5515,7 +5700,7 @@ }, { "kind": "error", - "id": 3010, + "id": 3110, "method": "session.workspace.checkpoint", "error": { "code": "operation_active", @@ -5525,7 +5710,7 @@ }, { "kind": "error", - "id": 3011, + "id": 3111, "method": "session.workspace.checkpoint", "error": { "code": "not_git_repository", @@ -5535,7 +5720,7 @@ }, { "kind": "error", - "id": 3012, + "id": 3112, "method": "session.workspace.checkpoint", "error": { "code": "git_unavailable", @@ -5545,7 +5730,7 @@ }, { "kind": "error", - "id": 3013, + "id": 3113, "method": "session.workspace.checkpoint", "error": { "code": "git_timeout", @@ -5555,7 +5740,7 @@ }, { "kind": "error", - "id": 3014, + "id": 3114, "method": "session.workspace.checkpoint", "error": { "code": "git_output_too_large", @@ -5565,7 +5750,7 @@ }, { "kind": "error", - "id": 3015, + "id": 3115, "method": "session.workspace.checkpoint", "error": { "code": "unsupported_git_state", @@ -5575,7 +5760,7 @@ }, { "kind": "error", - "id": 3016, + "id": 3116, "method": "session.workspace.checkpoint", "error": { "code": "unsupported_filename_encoding", @@ -5585,7 +5770,7 @@ }, { "kind": "error", - "id": 3017, + "id": 3117, "method": "session.workspace.checkpoint", "error": { "code": "checkpoint_unavailable", @@ -5595,7 +5780,7 @@ }, { "kind": "error", - "id": 3018, + "id": 3118, "method": "session.workspace.checkpoint", "error": { "code": "checkpoint_too_large", @@ -5605,7 +5790,7 @@ }, { "kind": "error", - "id": 3019, + "id": 3119, "method": "session.workspace.checkpoint", "error": { "code": "checkpoint_corrupt", @@ -5615,7 +5800,7 @@ }, { "kind": "error", - "id": 3101, + "id": 3201, "method": "session.blob.start", "error": { "code": "daemon_stopping", @@ -5625,7 +5810,7 @@ }, { "kind": "error", - "id": 3102, + "id": 3202, "method": "session.blob.start", "error": { "code": "bad_request", @@ -5635,7 +5820,7 @@ }, { "kind": "error", - "id": 3103, + "id": 3203, "method": "session.blob.start", "error": { "code": "not_initialized", @@ -5645,7 +5830,7 @@ }, { "kind": "error", - "id": 3104, + "id": 3204, "method": "session.blob.start", "error": { "code": "unsupported_capability", @@ -5655,7 +5840,7 @@ }, { "kind": "error", - "id": 3105, + "id": 3205, "method": "session.blob.start", "error": { "code": "rate_limited", @@ -5665,7 +5850,7 @@ }, { "kind": "error", - "id": 3106, + "id": 3206, "method": "session.blob.start", "error": { "code": "internal_error", @@ -5675,7 +5860,7 @@ }, { "kind": "error", - "id": 3107, + "id": 3207, "method": "session.blob.start", "error": { "code": "cancelled", @@ -5685,7 +5870,7 @@ }, { "kind": "error", - "id": 3108, + "id": 3208, "method": "session.blob.start", "error": { "code": "unknown_session", @@ -5695,7 +5880,7 @@ }, { "kind": "error", - "id": 3109, + "id": 3209, "method": "session.blob.start", "error": { "code": "event_migration_required", @@ -5705,7 +5890,7 @@ }, { "kind": "error", - "id": 3110, + "id": 3210, "method": "session.blob.start", "error": { "code": "invalid_media_type", @@ -5715,7 +5900,7 @@ }, { "kind": "error", - "id": 3111, + "id": 3211, "method": "session.blob.start", "error": { "code": "invalid_blob_name", @@ -5725,7 +5910,7 @@ }, { "kind": "error", - "id": 3112, + "id": 3212, "method": "session.blob.start", "error": { "code": "blob_too_large", @@ -5735,7 +5920,7 @@ }, { "kind": "error", - "id": 3113, + "id": 3213, "method": "session.blob.start", "error": { "code": "too_many_uploads", @@ -5745,7 +5930,7 @@ }, { "kind": "error", - "id": 3201, + "id": 3301, "method": "session.blob.chunk", "error": { "code": "daemon_stopping", @@ -5755,7 +5940,7 @@ }, { "kind": "error", - "id": 3202, + "id": 3302, "method": "session.blob.chunk", "error": { "code": "bad_request", @@ -5765,7 +5950,7 @@ }, { "kind": "error", - "id": 3203, + "id": 3303, "method": "session.blob.chunk", "error": { "code": "not_initialized", @@ -5775,7 +5960,7 @@ }, { "kind": "error", - "id": 3204, + "id": 3304, "method": "session.blob.chunk", "error": { "code": "unsupported_capability", @@ -5785,7 +5970,7 @@ }, { "kind": "error", - "id": 3205, + "id": 3305, "method": "session.blob.chunk", "error": { "code": "rate_limited", @@ -5795,7 +5980,7 @@ }, { "kind": "error", - "id": 3206, + "id": 3306, "method": "session.blob.chunk", "error": { "code": "internal_error", @@ -5805,7 +5990,7 @@ }, { "kind": "error", - "id": 3207, + "id": 3307, "method": "session.blob.chunk", "error": { "code": "cancelled", @@ -5815,7 +6000,7 @@ }, { "kind": "error", - "id": 3208, + "id": 3308, "method": "session.blob.chunk", "error": { "code": "unknown_session", @@ -5825,7 +6010,7 @@ }, { "kind": "error", - "id": 3209, + "id": 3309, "method": "session.blob.chunk", "error": { "code": "event_migration_required", @@ -5835,7 +6020,7 @@ }, { "kind": "error", - "id": 3210, + "id": 3310, "method": "session.blob.chunk", "error": { "code": "unknown_blob_upload", @@ -5845,7 +6030,7 @@ }, { "kind": "error", - "id": 3211, + "id": 3311, "method": "session.blob.chunk", "error": { "code": "invalid_blob_chunk", @@ -5855,7 +6040,7 @@ }, { "kind": "error", - "id": 3212, + "id": 3312, "method": "session.blob.chunk", "error": { "code": "blob_offset_mismatch", @@ -5865,7 +6050,7 @@ }, { "kind": "error", - "id": 3213, + "id": 3313, "method": "session.blob.chunk", "error": { "code": "blob_size_mismatch", @@ -5875,7 +6060,7 @@ }, { "kind": "error", - "id": 3214, + "id": 3314, "method": "session.blob.chunk", "error": { "code": "blob_write_failed", @@ -5885,7 +6070,7 @@ }, { "kind": "error", - "id": 3301, + "id": 3401, "method": "session.blob.commit", "error": { "code": "daemon_stopping", @@ -5895,7 +6080,7 @@ }, { "kind": "error", - "id": 3302, + "id": 3402, "method": "session.blob.commit", "error": { "code": "bad_request", @@ -5905,7 +6090,7 @@ }, { "kind": "error", - "id": 3303, + "id": 3403, "method": "session.blob.commit", "error": { "code": "not_initialized", @@ -5915,7 +6100,7 @@ }, { "kind": "error", - "id": 3304, + "id": 3404, "method": "session.blob.commit", "error": { "code": "unsupported_capability", @@ -5925,7 +6110,7 @@ }, { "kind": "error", - "id": 3305, + "id": 3405, "method": "session.blob.commit", "error": { "code": "rate_limited", @@ -5935,7 +6120,7 @@ }, { "kind": "error", - "id": 3306, + "id": 3406, "method": "session.blob.commit", "error": { "code": "internal_error", @@ -5945,7 +6130,7 @@ }, { "kind": "error", - "id": 3307, + "id": 3407, "method": "session.blob.commit", "error": { "code": "cancelled", @@ -5955,7 +6140,7 @@ }, { "kind": "error", - "id": 3308, + "id": 3408, "method": "session.blob.commit", "error": { "code": "unknown_session", @@ -5965,7 +6150,7 @@ }, { "kind": "error", - "id": 3309, + "id": 3409, "method": "session.blob.commit", "error": { "code": "event_migration_required", @@ -5975,7 +6160,7 @@ }, { "kind": "error", - "id": 3310, + "id": 3410, "method": "session.blob.commit", "error": { "code": "unknown_blob_upload", @@ -5985,7 +6170,7 @@ }, { "kind": "error", - "id": 3311, + "id": 3411, "method": "session.blob.commit", "error": { "code": "blob_size_mismatch", @@ -5995,7 +6180,7 @@ }, { "kind": "error", - "id": 3312, + "id": 3412, "method": "session.blob.commit", "error": { "code": "invalid_image", @@ -6005,7 +6190,7 @@ }, { "kind": "error", - "id": 3313, + "id": 3413, "method": "session.blob.commit", "error": { "code": "blob_corrupt", @@ -6015,7 +6200,7 @@ }, { "kind": "error", - "id": 3401, + "id": 3501, "method": "session.blob.abort", "error": { "code": "daemon_stopping", @@ -6025,7 +6210,7 @@ }, { "kind": "error", - "id": 3402, + "id": 3502, "method": "session.blob.abort", "error": { "code": "bad_request", @@ -6035,7 +6220,7 @@ }, { "kind": "error", - "id": 3403, + "id": 3503, "method": "session.blob.abort", "error": { "code": "not_initialized", @@ -6045,7 +6230,7 @@ }, { "kind": "error", - "id": 3404, + "id": 3504, "method": "session.blob.abort", "error": { "code": "unsupported_capability", @@ -6055,7 +6240,7 @@ }, { "kind": "error", - "id": 3405, + "id": 3505, "method": "session.blob.abort", "error": { "code": "rate_limited", @@ -6065,7 +6250,7 @@ }, { "kind": "error", - "id": 3406, + "id": 3506, "method": "session.blob.abort", "error": { "code": "internal_error", @@ -6075,7 +6260,7 @@ }, { "kind": "error", - "id": 3407, + "id": 3507, "method": "session.blob.abort", "error": { "code": "cancelled", @@ -6085,7 +6270,7 @@ }, { "kind": "error", - "id": 3408, + "id": 3508, "method": "session.blob.abort", "error": { "code": "unknown_session", @@ -6095,7 +6280,7 @@ }, { "kind": "error", - "id": 3409, + "id": 3509, "method": "session.blob.abort", "error": { "code": "event_migration_required", @@ -6105,7 +6290,7 @@ }, { "kind": "error", - "id": 3410, + "id": 3510, "method": "session.blob.abort", "error": { "code": "unknown_blob_upload", @@ -6115,7 +6300,7 @@ }, { "kind": "error", - "id": 3501, + "id": 3601, "method": "session.blob.read", "error": { "code": "daemon_stopping", @@ -6125,7 +6310,7 @@ }, { "kind": "error", - "id": 3502, + "id": 3602, "method": "session.blob.read", "error": { "code": "bad_request", @@ -6135,7 +6320,7 @@ }, { "kind": "error", - "id": 3503, + "id": 3603, "method": "session.blob.read", "error": { "code": "not_initialized", @@ -6145,7 +6330,7 @@ }, { "kind": "error", - "id": 3504, + "id": 3604, "method": "session.blob.read", "error": { "code": "unsupported_capability", @@ -6155,7 +6340,7 @@ }, { "kind": "error", - "id": 3505, + "id": 3605, "method": "session.blob.read", "error": { "code": "rate_limited", @@ -6165,7 +6350,7 @@ }, { "kind": "error", - "id": 3506, + "id": 3606, "method": "session.blob.read", "error": { "code": "internal_error", @@ -6175,7 +6360,7 @@ }, { "kind": "error", - "id": 3507, + "id": 3607, "method": "session.blob.read", "error": { "code": "cancelled", @@ -6185,7 +6370,7 @@ }, { "kind": "error", - "id": 3508, + "id": 3608, "method": "session.blob.read", "error": { "code": "unknown_session", @@ -6195,7 +6380,7 @@ }, { "kind": "error", - "id": 3509, + "id": 3609, "method": "session.blob.read", "error": { "code": "event_migration_required", @@ -6205,7 +6390,7 @@ }, { "kind": "error", - "id": 3510, + "id": 3610, "method": "session.blob.read", "error": { "code": "blob_not_owned", @@ -6215,7 +6400,7 @@ }, { "kind": "error", - "id": 3511, + "id": 3611, "method": "session.blob.read", "error": { "code": "blob_missing", @@ -6225,7 +6410,7 @@ }, { "kind": "error", - "id": 3512, + "id": 3612, "method": "session.blob.read", "error": { "code": "blob_corrupt", @@ -6235,7 +6420,7 @@ }, { "kind": "error", - "id": 3513, + "id": 3613, "method": "session.blob.read", "error": { "code": "invalid_blob_range", @@ -6245,7 +6430,7 @@ }, { "kind": "error", - "id": 3514, + "id": 3614, "method": "session.blob.read", "error": { "code": "blob_read_failed", @@ -6255,7 +6440,7 @@ }, { "kind": "error", - "id": 3601, + "id": 3701, "method": "session.dispose", "error": { "code": "daemon_stopping", @@ -6265,7 +6450,7 @@ }, { "kind": "error", - "id": 3602, + "id": 3702, "method": "session.dispose", "error": { "code": "bad_request", @@ -6275,7 +6460,7 @@ }, { "kind": "error", - "id": 3603, + "id": 3703, "method": "session.dispose", "error": { "code": "not_initialized", @@ -6285,7 +6470,7 @@ }, { "kind": "error", - "id": 3604, + "id": 3704, "method": "session.dispose", "error": { "code": "unsupported_capability", @@ -6295,7 +6480,7 @@ }, { "kind": "error", - "id": 3605, + "id": 3705, "method": "session.dispose", "error": { "code": "rate_limited", @@ -6305,7 +6490,7 @@ }, { "kind": "error", - "id": 3606, + "id": 3706, "method": "session.dispose", "error": { "code": "internal_error", @@ -6315,7 +6500,7 @@ }, { "kind": "error", - "id": 3607, + "id": 3707, "method": "session.dispose", "error": { "code": "cancelled", @@ -6325,7 +6510,7 @@ }, { "kind": "error", - "id": 3608, + "id": 3708, "method": "session.dispose", "error": { "code": "unknown_session", @@ -6335,7 +6520,7 @@ }, { "kind": "error", - "id": 3609, + "id": 3709, "method": "session.dispose", "error": { "code": "event_migration_required", @@ -6345,7 +6530,7 @@ }, { "kind": "error", - "id": 3610, + "id": 3710, "method": "session.dispose", "error": { "code": "invalid_idempotency_key", @@ -6355,7 +6540,7 @@ }, { "kind": "error", - "id": 3611, + "id": 3711, "method": "session.dispose", "error": { "code": "idempotency_conflict", @@ -6367,7 +6552,7 @@ "serverMessages": [ { "kind": "hello", - "wireVersion": 11, + "wireVersion": 12, "daemonInstanceId": "daemon-1", "capabilities": [ "session.create", @@ -6549,6 +6734,38 @@ "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, "timestamp": 1725000000008, + "type": "interrupt.requested", + "payload": { + "state": "queued", + "content": [ + { + "type": "text", + "text": "replacement" + } + ], + "targetOperationId": "00000000-0000-4000-8000-000000000010" + } + }, + { + "version": 1, + "id": "00000000-0000-4000-8000-00000000000a", + "sessionId": "123e4567-e89b-42d3-a456-426614174000", + "operationId": "00000000-0000-4000-8000-000000000010", + "parentId": null, + "timestamp": 1725000000009, + "type": "interrupt.updated", + "payload": { + "state": "delivered", + "targetOperationId": "00000000-0000-4000-8000-000000000010" + } + }, + { + "version": 1, + "id": "00000000-0000-4000-8000-00000000000b", + "sessionId": "123e4567-e89b-42d3-a456-426614174000", + "operationId": "00000000-0000-4000-8000-000000000010", + "parentId": null, + "timestamp": 1725000000010, "type": "user.shell", "payload": { "command": "pwd", @@ -6564,11 +6781,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000000a", + "id": "00000000-0000-4000-8000-00000000000c", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000009, + "timestamp": 1725000000011, "type": "assistant.message", "payload": { "content": [ @@ -6594,11 +6811,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000000b", + "id": "00000000-0000-4000-8000-00000000000d", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000010, + "timestamp": 1725000000012, "type": "model.retry_scheduled", "payload": { "attempt": 2, @@ -6609,11 +6826,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000000c", + "id": "00000000-0000-4000-8000-00000000000e", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000011, + "timestamp": 1725000000013, "type": "tool.call", "payload": { "callId": "call-1", @@ -6625,11 +6842,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000000d", + "id": "00000000-0000-4000-8000-00000000000f", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000012, + "timestamp": 1725000000014, "type": "tool.result", "payload": { "callId": "call-1", @@ -6648,11 +6865,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000000e", + "id": "00000000-0000-4000-8000-000000000010", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000013, + "timestamp": 1725000000015, "type": "config.request", "payload": { "maxOutputTokens": null, @@ -6661,11 +6878,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000000f", + "id": "00000000-0000-4000-8000-000000000011", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000014, + "timestamp": 1725000000016, "type": "model.request_configured", "payload": { "maxOutputTokens": 8192, @@ -6678,11 +6895,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000010", + "id": "00000000-0000-4000-8000-000000000012", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000015, + "timestamp": 1725000000017, "type": "config.model", "payload": { "modelId": "model-1" @@ -6690,11 +6907,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000011", + "id": "00000000-0000-4000-8000-000000000013", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000016, + "timestamp": 1725000000018, "type": "config.provider", "payload": { "providerId": "provider-1" @@ -6702,11 +6919,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000012", + "id": "00000000-0000-4000-8000-000000000014", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000017, + "timestamp": 1725000000019, "type": "config.entitlement", "payload": { "entitlementId": "credential-reference" @@ -6714,11 +6931,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000013", + "id": "00000000-0000-4000-8000-000000000015", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000018, + "timestamp": 1725000000020, "type": "config.profile", "payload": { "profile": "standard" @@ -6726,11 +6943,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000014", + "id": "00000000-0000-4000-8000-000000000016", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000019, + "timestamp": 1725000000021, "type": "config.thinking", "payload": { "requested": "high", @@ -6740,11 +6957,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000015", + "id": "00000000-0000-4000-8000-000000000017", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000020, + "timestamp": 1725000000022, "type": "config.tools", "payload": { "webFetch": true, @@ -6753,11 +6970,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000016", + "id": "00000000-0000-4000-8000-000000000018", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000021, + "timestamp": 1725000000023, "type": "config.dialect", "payload": { "dialectId": "openai-chat", @@ -6767,11 +6984,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000017", + "id": "00000000-0000-4000-8000-000000000019", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000022, + "timestamp": 1725000000024, "type": "prompt.section", "payload": { "name": "identity", @@ -6781,11 +6998,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000018", + "id": "00000000-0000-4000-8000-00000000001a", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000023, + "timestamp": 1725000000025, "type": "tool.schema", "payload": { "name": "read", @@ -6798,11 +7015,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000019", + "id": "00000000-0000-4000-8000-00000000001b", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000024, + "timestamp": 1725000000026, "type": "context.injected", "payload": { "source": "skill", @@ -6811,11 +7028,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000001a", + "id": "00000000-0000-4000-8000-00000000001c", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000025, + "timestamp": 1725000000027, "type": "context.extension", "payload": { "extensionId": "example", @@ -6825,11 +7042,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000001b", + "id": "00000000-0000-4000-8000-00000000001d", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000026, + "timestamp": 1725000000028, "type": "permission.requested", "payload": { "capability": "filesystem.write", @@ -6838,11 +7055,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000001c", + "id": "00000000-0000-4000-8000-00000000001e", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000027, + "timestamp": 1725000000029, "type": "permission.resolved", "payload": { "requestId": "00000000-0000-4000-8000-000000000001", @@ -6851,11 +7068,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000001d", + "id": "00000000-0000-4000-8000-00000000001f", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000028, + "timestamp": 1725000000030, "type": "interaction.requested", "payload": { "interactionId": "interaction-1", @@ -6871,11 +7088,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000001e", + "id": "00000000-0000-4000-8000-000000000020", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000029, + "timestamp": 1725000000031, "type": "interaction.resolved", "payload": { "interactionId": "interaction-1", @@ -6887,11 +7104,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-00000000001f", + "id": "00000000-0000-4000-8000-000000000021", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000030, + "timestamp": 1725000000032, "type": "sandbox.configured", "payload": { "provider": "bubblewrap", @@ -6904,11 +7121,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000020", + "id": "00000000-0000-4000-8000-000000000022", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000031, + "timestamp": 1725000000033, "type": "sandbox.violation", "payload": { "capability": "filesystem.write", @@ -6917,11 +7134,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000021", + "id": "00000000-0000-4000-8000-000000000023", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000032, + "timestamp": 1725000000034, "type": "context.compacted", "payload": { "summary": "Earlier work", @@ -6930,11 +7147,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000022", + "id": "00000000-0000-4000-8000-000000000024", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000033, + "timestamp": 1725000000035, "type": "session.error", "payload": { "code": "provider_failed", @@ -6944,11 +7161,11 @@ }, { "version": 1, - "id": "00000000-0000-4000-8000-000000000023", + "id": "00000000-0000-4000-8000-000000000025", "sessionId": "123e4567-e89b-42d3-a456-426614174000", "operationId": "00000000-0000-4000-8000-000000000010", "parentId": null, - "timestamp": 1725000000034, + "timestamp": 1725000000036, "type": "child.result", "payload": { "childSessionId": "123e4567-e89b-42d3-a456-426614174001", diff --git a/packages/protocol/test/version.test.ts b/packages/protocol/test/version.test.ts index 1d718b3..29d4c58 100644 --- a/packages/protocol/test/version.test.ts +++ b/packages/protocol/test/version.test.ts @@ -8,7 +8,7 @@ import test from "node:test"; import { EVENT_FORMAT_VERSION, WIRE_PROTOCOL_VERSION } from "../src/index.ts"; -test("keeps event format 1 and adds model request configuration in wire protocol 11", () => { +test("keeps event format 1 and adds interrupt delivery in wire protocol 12", () => { assert.equal(EVENT_FORMAT_VERSION, 1); - assert.equal(WIRE_PROTOCOL_VERSION, 11); + assert.equal(WIRE_PROTOCOL_VERSION, 12); }); diff --git a/packages/protocol/test/wire.test.ts b/packages/protocol/test/wire.test.ts index a32a3a0..40eb61f 100644 --- a/packages/protocol/test/wire.test.ts +++ b/packages/protocol/test/wire.test.ts @@ -29,6 +29,7 @@ test("maps feature methods to negotiated capabilities", () => { assert.equal(requiredCapability("request.cancel"), undefined); assert.equal(requiredCapability("session.history"), undefined); assert.equal(requiredCapability("session.send"), "session.send.prompt"); + assert.equal(requiredCapability("session.interruptAndDeliver"), "session.interrupt_deliver"); assert.equal(requiredCapability("session.blob.abort"), "session.blob.abort"); }); @@ -111,6 +112,12 @@ test("validates every request shape", () => { }, }, { kind: "request", id: 5, method: "session.interrupt", params: { sessionId } }, + { + kind: "request", + id: 26, + method: "session.interruptAndDeliver", + params: { sessionId, content: [{ type: "text", text: "replacement" }] }, + }, { kind: "request", id: 5, diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 4cc23f7..b402f22 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -218,4 +218,6 @@ Host messages use their own version and connection on the protected Unix socket. ## Model request configuration -Wire version 11 adds `requestSettings` to session create and configure RPCs. `config.request` records the selected output and transport-idle settings. `model.request_configured` records each effective output ceiling, idle timeout, estimated input, context reserve, context window, and model maximum before dispatch. `ConversationProjector` exposes these as `requestSettings` and `lastRequest` so every client can present the same daemon-owned values. +Wire version 11 added `requestSettings` to session create and configure RPCs. `config.request` records the selected output and transport-idle settings. `model.request_configured` records each effective output ceiling, idle timeout, estimated input, context reserve, context window, and model maximum before dispatch. `ConversationProjector` exposes these as `requestSettings` and `lastRequest` so every client can present the same daemon-owned values. + +Wire version 12 adds `session.interruptAndDeliver`. The idempotent operation stops active work at a safe boundary and delivers replacement content exactly once. If no operation is active, it behaves as an ordinary send. Clients must use this operation instead of composing separate interrupt and send requests. diff --git a/packages/sdk/src/projector.ts b/packages/sdk/src/projector.ts index b9e139d..772f696 100644 --- a/packages/sdk/src/projector.ts +++ b/packages/sdk/src/projector.ts @@ -90,6 +90,15 @@ export interface ProjectedQueueItem { readonly status: "queued" | "running" | "paused" | "completed" | "failed" | "aborted"; } +export interface ProjectedInterruptDelivery { + readonly requestEventId: EventId; + readonly operationId?: OperationId; + readonly content: EventPayloadMap["interrupt.requested"]["content"]; + readonly targetOperationId?: OperationId; + readonly reason?: string; + readonly status: "queued" | "interrupting" | "delivered" | "failed"; +} + export interface ConversationState { readonly sessionId?: SessionId; readonly selectedNodeId?: EventId; @@ -100,6 +109,7 @@ export interface ConversationState { readonly activeOperationId?: OperationId; readonly uncertainShellOperations: readonly UncertainShellOperation[]; readonly queue: readonly ProjectedQueueItem[]; + readonly interruptDeliveries: readonly ProjectedInterruptDelivery[]; readonly model?: string; readonly provider?: string; readonly entitlement?: string; @@ -120,16 +130,23 @@ export interface ConversationState { /** Status and activity without materializing the accumulated conversation collections. */ export type ConversationOverview = Omit< ConversationState, - "records" | "tools" | "interactions" | "operations" | "queue" | "uncertainShellOperations" + | "records" + | "tools" + | "interactions" + | "operations" + | "queue" + | "interruptDeliveries" + | "uncertainShellOperations" > & { readonly recordCount: number }; -/** Display order for locally pending inputs under the daemon's steer-before-follow-up contract. +/** Display order for locally pending inputs under the daemon's delivery contract. * This is a projection, not a queue or a complete view of inputs from other clients. */ -export function orderPendingTurnInputs( - inputs: readonly T[], -): readonly T[] { - return inputs.toSorted((a, b) => Number(a.mode === "followUp") - Number(b.mode === "followUp")); +export function orderPendingTurnInputs< + T extends { readonly mode: "steer" | "followUp" | "interrupt" }, +>(inputs: readonly T[]): readonly T[] { + const rank = { interrupt: 0, steer: 1, followUp: 2 } as const; + return inputs.toSorted((a, b) => rank[a.mode] - rank[b.mode]); } const MAX_PROJECTED_ACTIVITY_CHARACTERS = 131_072; @@ -198,6 +215,7 @@ export class ConversationProjector { private readonly operations = new Map(); private readonly uncertainShellOperations = new Map(); private readonly queue = new Map(); + private readonly interruptDeliveries = new Map(); private activeOperationId: OperationId | undefined; private model: string | undefined; private provider: string | undefined; @@ -239,6 +257,7 @@ export class ConversationProjector { operations: Object.freeze([...this.operations.values()]), uncertainShellOperations: Object.freeze([...this.uncertainShellOperations.values()]), queue: Object.freeze([...this.queue.values()]), + interruptDeliveries: Object.freeze([...this.interruptDeliveries.values()]), }); } @@ -290,6 +309,7 @@ export class ConversationProjector { this.activeOperationId = undefined; if (!keepUncertainShells) this.uncertainShellOperations.clear(); this.queue.clear(); + this.interruptDeliveries.clear(); this.model = undefined; this.provider = undefined; this.entitlement = undefined; @@ -356,6 +376,47 @@ export class ConversationProjector { case "queue.paused": this.updateQueueItem(event.payload.queueItemId, { status: "paused" }); break; + case "interrupt.requested": + if (event.operationId === undefined) { + throw new ProjectionError( + "interrupt_identity_conflict", + `Interrupt request ${event.id} has no operation identity`, + ); + } + this.interruptDeliveries.set(event.operationId, { + requestEventId: event.id, + operationId: event.operationId, + content: event.payload.content, + ...(event.payload.targetOperationId === undefined + ? {} + : { targetOperationId: event.payload.targetOperationId }), + status: "queued", + }); + break; + case "interrupt.updated": { + if (event.operationId === undefined) { + throw new ProjectionError( + "interrupt_identity_conflict", + `Interrupt update ${event.id} has no operation identity`, + ); + } + const delivery = this.interruptDeliveries.get(event.operationId); + if (delivery === undefined) { + throw new ProjectionError( + "interrupt_identity_conflict", + `Interrupt update ${event.id} has no matching request`, + ); + } + this.interruptDeliveries.set(event.operationId, { + ...delivery, + status: event.payload.state, + ...(event.payload.targetOperationId === undefined + ? {} + : { targetOperationId: event.payload.targetOperationId }), + ...(event.payload.reason === undefined ? {} : { reason: event.payload.reason }), + }); + break; + } case "user.shell": this.updateOperation(event.operationId, event.payload.isError ? "failed" : "succeeded"); if (event.operationId !== undefined) diff --git a/packages/sdk/test/projector.test.ts b/packages/sdk/test/projector.test.ts index 0fc704f..067a4fa 100644 --- a/packages/sdk/test/projector.test.ts +++ b/packages/sdk/test/projector.test.ts @@ -33,6 +33,7 @@ function event( type: Type, payload: EventPayloadMap[Type], parentId: string | null = null, + operationId?: ReturnType, ): CanonicalEvent { counter += 1; return parseEvent({ @@ -40,6 +41,7 @@ function event( id: `00000000-0000-4000-8000-${counter.toString(16).padStart(12, "0")}`, sessionId, parentId, + ...(operationId === undefined ? {} : { operationId }), timestamp: counter, type, payload, @@ -296,6 +298,7 @@ test("overview reads remain history-free for a 100,000-event session", () => { interactions: _interactions, operations: _operations, queue: _queue, + interruptDeliveries: _interruptDeliveries, uncertainShellOperations: _uncertain, ...metadata } = full; @@ -337,19 +340,52 @@ test("projects compacted membership across repeated summaries without deleting r assert.equal(projector.isEventCompacted(old.id), false); }); -test("pending-input presentation follows steering FIFO before follow-up FIFO without mutating submissions", () => { +test("projects canonical interrupt delivery state", () => { + const projector = new ConversationProjector(sessionId); + const operationId = parseOperationId("00000000-0000-4000-8000-000000000100"); + const requested = event( + "interrupt.requested", + { + state: "queued", + content: [{ type: "text", text: "replace the task" }], + }, + null, + operationId, + ); + projector.applyEvent(requested); + assert.deepEqual(projector.state.interruptDeliveries, [ + { + requestEventId: requested.id, + operationId, + content: [{ type: "text", text: "replace the task" }], + status: "queued", + }, + ]); + + const delivered = event("interrupt.updated", { state: "delivered" }, requested.id, operationId); + projector.applyEvent(delivered); + assert.deepEqual(projector.state.interruptDeliveries[0], { + requestEventId: requested.id, + operationId, + content: [{ type: "text", text: "replace the task" }], + status: "delivered", + }); +}); + +test("pending-input presentation follows interruption, steering, then follow-up order", () => { const submitted = [ { mode: "followUp", text: "f1" }, { mode: "steer", text: "s1" }, + { mode: "interrupt", text: "i1" }, { mode: "followUp", text: "f2" }, { mode: "steer", text: "s2" }, ] as const; assert.deepEqual( orderPendingTurnInputs(submitted).map((item) => item.text), - ["s1", "s2", "f1", "f2"], + ["i1", "s1", "s2", "f1", "f2"], ); assert.deepEqual( submitted.map((item) => item.text), - ["f1", "s1", "f2", "s2"], + ["f1", "s1", "i1", "f2", "s2"], ); }); diff --git a/packages/tui/README.md b/packages/tui/README.md index 08c52fb..fabaac1 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -28,7 +28,7 @@ The TUI includes: - Atomic global preferences in `~/.axl/settings.json` for model, thinking, web-tool availability, theme, and terminal presentation - Sandboxed `!command` passthrough, plus context-excluded `!!command` passthrough - Model and thinking changes recorded by the daemon -- Daemon-owned steering with Enter and ordered follow-ups with Alt+Enter while a model turn runs; prompts entered during shell or compaction work wait locally +- Daemon-owned steering with Enter, ordered follow-ups with Alt+Enter, and atomic interrupt-and-deliver with Ctrl+Enter while a model turn runs; prompts entered during shell or compaction work wait locally - GFM Markdown, broad language-aware syntax highlighting across code fences, file reads, edit/write previews, and workspace diffs, Unicode Mermaid diagrams, safe visible links, bordered prompts, retained tool transactions, bounded shell output, and line-numbered diffs that switch between unified and split views - A framed editor showing token use, cache rate, cost, context, model, effort, path, Git branch, and local throughput - Clean resize reconstruction, interruption, detach, daemon restart reconnect, searchable all-placement session resume with visible unsafe labels, fork, clone, and visible connection state @@ -91,11 +91,11 @@ Run `/commands` for searchable actions, `/hotkeys` for keybindings, `/details` f Ctrl plus V pastes an image or text. Images are saved to owner-only `axl-clipboard-` files in the operating system's temporary directory, and their paths appear in the draft. On submission, intact standalone paths created by this client are uploaded through the daemon blob channel. Delete a path from the draft to omit that image. Temp files remain available for reuse until the operating system removes them; use `/attach ` to reuse one after restarting Axl. Clipboard reading uses `wl-paste` on Wayland, `xclip` on X11, AppKit through `osascript` on macOS, and PowerShell on Windows or WSL. Missing helpers and unsupported image formats produce visible errors. -During a model turn, Enter sends steering after the current complete tool-call batch and Alt plus Enter queues a follow-up after the turn would otherwise finish. Pending messages from this terminal appear above the editor in numbered injection order: steering FIFO, then follow-up FIFO. Consumed messages disappear and the remaining positions update. This list does not include pending steering from other clients. Dropping image paths attaches them to the next prompt. `/attach ` provides an explicit keyboard flow, while `/attach clear` removes pending attachments and clears clipboard-path recognition. Image display can be set to auto, inline, or metadata in `/settings`. See `docs/terminal-compatibility.md` for capability overrides and the manual terminal matrix. +During a model turn, Enter sends steering after the current complete tool-call batch, Alt plus Enter queues a follow-up after the turn would otherwise finish, and Ctrl plus Enter interrupts at a safe boundary before delivering the entered text as a new turn. Pending messages from this terminal appear above the editor in numbered delivery order. Consumed messages disappear and the remaining positions update. This list does not include pending input from other clients. Dropping image paths attaches them to the next prompt. `/attach ` provides an explicit keyboard flow, while `/attach clear` removes pending attachments and clears clipboard-path recognition. Image display can be set to auto, inline, or metadata in `/settings`. See `docs/terminal-compatibility.md` for capability overrides and the manual terminal matrix. Fullscreen navigation uses Page Up and Page Down, Shift plus Page Up and Page Down for half pages, Alt plus Up and Down for lines, Home and End for transcript bounds, Ctrl plus Shift plus Up and Down for prompt jumps, and Ctrl plus F for search. Enter selects the next search match, Shift plus Enter selects the previous match, and Escape closes search. Mouse capture can be changed to native terminal selection in `/settings`. -Shift plus Enter depends on the terminal reporting a modified Enter sequence. Some Ubuntu terminal configurations send the same carriage-return byte for Shift plus Enter and Enter, which no terminal application can distinguish. `Ctrl+J` and backslash followed by Enter remain portable newline alternatives until terminal-specific setup guidance is completed. +Shift plus Enter and Ctrl plus Enter depend on the terminal reporting a modified Enter sequence. Some terminal configurations send the same carriage-return byte for modified and unmodified Enter, which no terminal application can distinguish. `Ctrl+J` and backslash followed by Enter remain portable newline alternatives. Use plain Escape to cancel without delivering replacement text. Ctrl plus Backspace also depends on a distinct terminal sequence. When a terminal sends the ordinary Backspace byte for both keys, use Alt plus Backspace or Ctrl plus W for word deletion. Ordinary Backspace always remains single-grapheme deletion. diff --git a/packages/tui/src/app.ts b/packages/tui/src/app.ts index 2dbb6a5..a7ff5d1 100644 --- a/packages/tui/src/app.ts +++ b/packages/tui/src/app.ts @@ -351,6 +351,7 @@ function isReservedExtensionShortcut(value: string): boolean { decoded.key.kind === "enter" || decoded.key.kind === "newline" || decoded.key.kind === "follow-up" || + decoded.key.kind === "interrupt-deliver" || decoded.key.kind === "escape" ) { return true; @@ -365,6 +366,7 @@ const HOTKEYS: readonly { readonly key: string; readonly action: string }[] = [ { key: "Shift+Enter / Ctrl+J", action: "Insert a newline" }, { key: "\\ then Enter", action: "Insert a newline in every terminal" }, { key: "Alt+Enter", action: "Queue a follow-up after the active turn" }, + { key: "Ctrl+Enter", action: "Interrupt the active turn and deliver this prompt" }, { key: "Ctrl+A", action: "Select the entire prompt" }, { key: "Ctrl+C", action: "Copy selection or clear; press twice within 500 ms to quit" }, { key: "Ctrl+X", action: "Cut the selection" }, @@ -400,8 +402,8 @@ const HOTKEYS: readonly { readonly key: string; readonly action: string }[] = [ ]; const KEY_HELP: readonly string[] = [ - "Enter send/steer · Alt+Enter follow-up · Shift+Enter/Ctrl+J newline", - "Esc interrupts · Ctrl+C clears · Ctrl+O tool details · /hotkeys for every shortcut", + "Enter send/steer · Alt+Enter follow-up · Ctrl+Enter interrupt and deliver", + "Shift+Enter/Ctrl+J newline · Esc interrupts · Ctrl+C clears · /hotkeys for every shortcut", ]; function themePreview(width: number, palette: Palette): readonly string[] { @@ -581,7 +583,7 @@ export class AxlApp { readonly attachments: readonly BlobReference[]; }> = []; private readonly pendingTurnInputs: Array<{ - readonly mode: "steer" | "followUp"; + readonly mode: "steer" | "followUp" | "interrupt"; readonly contentKey: string; readonly text: string; }> = []; @@ -1240,7 +1242,7 @@ export class AxlApp { const pending = orderPendingTurnInputs(this.pendingTurnInputs); const rows = pending.map( (item, index) => - `${index + 1}. ${item.mode === "steer" ? "Steering" : "Follow-up"}: ${extensionSingleLine(item.text) || "[attachment]"}`, + `${index + 1}. ${item.mode === "steer" ? "Steering" : item.mode === "followUp" ? "Follow-up" : "Interrupt"}: ${extensionSingleLine(item.text) || "[attachment]"}`, ); return [ ...(rows.length === 0 @@ -1250,7 +1252,7 @@ export class AxlApp { ? [ this.activeRequest === "shell" || this.activeRequest === "compaction" ? "Enter queues a follow-up · Esc cancels" - : "Enter steers next · Alt+Enter follows up after the turn", + : "Enter steers next · Alt+Enter follows up · Ctrl+Enter interrupts and delivers", ] : []), ].map((line) => this.view.palette.dim(truncateToWidth(line, width, "…"))); @@ -1819,7 +1821,7 @@ export class AxlApp { } else if (key.kind === "tab") { if (!this.acceptCompletion()) this.editor.apply(key); } else if (key.kind === "escape") { - if (this.view.working) void this.interrupt(); + if (this.view.working || this.sending) void this.interrupt(); else if (this.editorMode === "vim") this.vim.handle(key, this.editor); else { this.editor.clear(); @@ -1827,7 +1829,11 @@ export class AxlApp { } } else if (this.editorMode === "vim" && this.vim.handle(key, this.editor)) { this.notice = undefined; - } else if (key.kind === "enter" || key.kind === "follow-up") { + } else if ( + key.kind === "enter" || + key.kind === "follow-up" || + key.kind === "interrupt-deliver" + ) { const matches = key.kind === "enter" ? this.completionMatches() : []; const selected = matches[this.completionIndex]; if (selected !== undefined && selected !== this.editor.text) { @@ -1836,7 +1842,13 @@ export class AxlApp { const line = this.editor.apply({ kind: "enter" }); if (line !== undefined) { this.vim.reset(); - void this.submit(line.trim(), key.kind === "follow-up").catch((error: unknown) => { + const delivery = + key.kind === "follow-up" + ? "followUp" + : key.kind === "interrupt-deliver" + ? "interrupt" + : "default"; + void this.submit(line.trim(), delivery).catch((error: unknown) => { this.editor.setText([line, this.editor.text].filter(Boolean).join("\n\n")); this.notice = this.view.palette.error( `✖ ${error instanceof Error ? error.message : "submission failed"} · prompt restored`, @@ -2226,7 +2238,10 @@ export class AxlApp { } } - private async submit(inputLine: string, prioritize = false): Promise { + private async submit( + inputLine: string, + delivery: "default" | "followUp" | "interrupt" = "default", + ): Promise { this.notice = undefined; if (this.clipboardBusy || this.attachmentBusy) { this.editor.setText([inputLine, this.editor.text].filter(Boolean).join("\n\n")); @@ -2554,19 +2569,23 @@ export class AxlApp { ], }; this.pendingAttachments.length = 0; + if (delivery === "interrupt" && (this.sending || this.view.working)) { + void this.queueDuringTurn(queued, "interrupt"); + return; + } if ( this.sessionSubscription?.projector.overview.activeOperationId !== undefined && this.activeRequest !== "shell" && this.activeRequest !== "compaction" ) { - void this.queueDuringTurn(queued, prioritize ? "followUp" : "steer"); + void this.queueDuringTurn(queued, delivery === "followUp" ? "followUp" : "steer"); return; } if (this.sending || this.view.working) { this.notice = this.view.palette.dim("· queueing follow-up"); this.invalidateFullscreenRows(); this.redraw(); - void this.enqueuePrompt(queued, prioritize ? "front" : "back"); + void this.enqueuePrompt(queued, delivery === "followUp" ? "front" : "back"); return; } this.queued.push(queued); @@ -4274,7 +4293,7 @@ export class AxlApp { private async queueDuringTurn( queued: { readonly text: string; readonly attachments: readonly BlobReference[] }, - mode: "steer" | "followUp", + mode: "steer" | "followUp" | "interrupt", ): Promise { const params = { sessionId: this.sessionId, @@ -4287,7 +4306,8 @@ export class AxlApp { this.pendingTurnInputs.push(pending); try { if (mode === "steer") await this.client.request("session.steer", params); - else await this.client.request("session.followUp", params); + else if (mode === "followUp") await this.client.request("session.followUp", params); + else await this.client.request("session.interruptAndDeliver", params); } catch (error) { const pendingIndex = this.pendingTurnInputs.indexOf(pending); if (pendingIndex >= 0) { @@ -4454,15 +4474,17 @@ export class AxlApp { if (this.interrupting) return; this.interrupting = true; try { - let result = await this.client.request("session.interrupt", { sessionId: this.sessionId }); - // Working is shown optimistically before session.send installs daemon ownership. - // Preserve an immediate Escape across that short admission window. - while (!result.interrupted && this.sending && !this.stopped) { - await new Promise((resolvePromise) => setTimeout(resolvePromise, 25)); - result = await this.client.request("session.interrupt", { sessionId: this.sessionId }); + const result = await this.client.request("session.interrupt", { + sessionId: this.sessionId, + }); + if (!result.interrupted) { + this.notice = this.view.palette.dim("· no active operation to interrupt"); + this.redraw(); } - } catch { - this.notice = this.view.palette.dim("· turn already finished"); + } catch (error) { + this.notice = this.view.palette.error( + `✖ ${error instanceof Error ? error.message : "interrupt failed"}`, + ); this.redraw(); } finally { this.interrupting = false; diff --git a/packages/tui/src/editor.ts b/packages/tui/src/editor.ts index 932c61b..e00551e 100644 --- a/packages/tui/src/editor.ts +++ b/packages/tui/src/editor.ts @@ -12,7 +12,7 @@ export type EditorKey = readonly kind: "select-left" | "select-right" | "select-word-left" | "select-word-right"; } | { readonly kind: "backspace" | "delete" } - | { readonly kind: "enter" | "newline" | "follow-up" | "redo" } + | { readonly kind: "enter" | "newline" | "follow-up" | "interrupt-deliver" | "redo" } | { readonly kind: "tab" | "shift-tab" | "escape" } | { readonly kind: "paste-start" | "paste-end" } | { readonly kind: "ctrl" | "alt"; readonly char: string } @@ -24,8 +24,9 @@ function kittyKey(code: number, modifier = 1): EditorKey { const alt = (bits & 2) !== 0; const ctrl = (bits & 4) !== 0; if (code === 13) { + if (ctrl) return { kind: "interrupt-deliver" }; if (alt) return { kind: "follow-up" }; - if (shift || ctrl) return { kind: "newline" }; + if (shift) return { kind: "newline" }; return { kind: "enter" }; } if (ctrl && shift && code === 122) return { kind: "redo" }; diff --git a/packages/tui/test/app.test.ts b/packages/tui/test/app.test.ts index 933db4a..e36a4af 100644 --- a/packages/tui/test/app.test.ts +++ b/packages/tui/test/app.test.ts @@ -1126,10 +1126,12 @@ test("Ctrl+V paste, Shift+Enter, and searchable hotkeys behave", async (context) app.stop(); }); -test("Escape interrupts a running operation", async (context) => { +test("Escape interrupts a running or admitted operation", async (context) => { + let modelCalls = 0; let operationAborted = false; const blockingPort: ModelPort = { stream(request) { + modelCalls += 1; return (async function* (): AsyncGenerator { await new Promise((resolve) => { if (request.signal?.aborted) resolve(); @@ -1155,10 +1157,30 @@ test("Escape interrupts a running operation", async (context) => { input.write("start work\r"); await until(() => text().includes("Working"), "working state"); input.write("\x1b[27u"); - await until(() => operationAborted, "escape interruption"); + await until(() => text().includes("interrupted"), "escape interruption"); + assert.equal(modelCalls === 0 || operationAborted, true); app.stop(); }); +test("an idle Escape result is visible and is not polled", async (context) => { + const { socketPath, directory } = await startStack(context); + const input = new PassThrough(); + const { output, text } = captureOutput(); + const client = await connectUnixClient(socketPath); + const rpc = context.mock.method(client, "request"); + const app = await AxlApp.start({ client, input, output, cwd: directory, color: false }); + context.after(() => app.stop()); + const before = rpc.mock.calls.filter((call) => call.arguments[0] === "session.interrupt").length; + + await (app as unknown as { interrupt(): Promise }).interrupt(); + + await until(() => text().includes("no active operation to interrupt"), "interrupt notice"); + assert.equal( + rpc.mock.calls.filter((call) => call.arguments[0] === "session.interrupt").length, + before + 1, + ); +}); + for (const submission of ["local", "other attachment"] as const) { for (const terminal of ["session.error", "tool abort"] as const) { test(`${submission} ${terminal} clears Working and accepts the next prompt`, async (context) => { @@ -1656,6 +1678,51 @@ test("pending steering and follow-ups display their actual injection order above app.stop(); }); +test("Ctrl+Enter interrupts the active turn and delivers replacement input", async (context) => { + let calls = 0; + const prompts: string[] = []; + const model: ModelPort = { + stream(request) { + calls += 1; + const call = calls; + const last = request.messages.findLast((message) => message.role === "user"); + if (last?.role === "user") { + prompts.push(last.content.map((item) => (item.type === "text" ? item.text : "")).join("")); + } + return (async function* (): AsyncGenerator { + if (call === 1) { + await new Promise((resolvePromise) => { + if (request.signal?.aborted) return resolvePromise(); + request.signal?.addEventListener("abort", () => resolvePromise(), { once: true }); + }); + yield { type: "aborted" }; + return; + } + yield { type: "text_delta", text: "replacement complete" }; + yield { type: "completed", stopReason: "stop", usage }; + })(); + }, + }; + const { socketPath, directory } = await startStack(context, model); + const input = new PassThrough(); + const { output, text } = captureOutput(); + const app = await AxlApp.start({ + client: await connectUnixClient(socketPath), + input, + output, + cwd: directory, + color: false, + }); + context.after(() => app.stop()); + + await until(() => text().includes("\x1b[>4;2m"), "keyboard negotiation"); + input.write("obsolete work\r"); + await until(() => calls === 1, "active model call"); + input.write("do this instead\x1b[13;5u"); + await until(() => text().includes("replacement complete"), "interrupt replacement"); + assert.deepEqual(prompts, ["obsolete work", "do this instead"]); +}); + test("MCP interactions block the operation until the user responds", async (context) => { let call = 0; const interactiveModel: ModelPort = { diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index a50801e..be75783 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -26,6 +26,7 @@ test("decodes characters, controls, and CSI sequences", () => { assert.deepEqual(decodeKeys("\x1b[27;2;13~"), [{ kind: "newline" }]); assert.deepEqual(decodeKeys("\x1b[13;2~"), [{ kind: "newline" }]); assert.deepEqual(decodeKeys("\x1b[13;3u"), [{ kind: "follow-up" }]); + assert.deepEqual(decodeKeys("\x1b[13;5u"), [{ kind: "interrupt-deliver" }]); assert.deepEqual(decodeKeys("\x01"), [{ kind: "ctrl", char: "a" }]); assert.deepEqual(decodeKeys("\x1b[1;2D"), [{ kind: "select-left" }]); assert.deepEqual(decodeKeys("\x1b[122;6u"), [{ kind: "redo" }]);