Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,15 @@ In TUI chat mode there is no completion gate — the session stays open across t
- **Mid-run injection** — Shell `session-queue` items drain at the parent
`tool.boundary` through `SessionPort.deliver`. Production `routeQueuedDelivery`
live-injects in-flight parent-boundary steers via `agentProxy.deliver`
(`Agent.deliver`) into the live reactor. Idle leftover, idle-with-fleet, and
(`Agent.deliver`) into one captured agent identity — never re-reading
`currentAgent` after enqueue, so a rebuild cannot silently retarget the
message. Idle leftover, idle-with-fleet, and
post-interrupt steers, plus follow-ups (`kind === "queue"`), use the existing
send path. `/clear` and `/new` bump a
send path. A definitive `AgentClosedError` (or other not-delivered /
uncertain settlement) returns ownership to bridge-owned prompt recovery
rather than forwarding to a successor. `/clear` and `/new` bump a
delivery generation and call `SessionBridge.clearQueuedDelivery()` so queued
input from the previous session cannot enter the new one.
input and deferred recoveries from the previous session cannot enter the new one.
- **Session rotation** — Uses a serial session-operation queue (`createSessionOperationQueue`, not a boolean flag) so rotation, compaction continuation, and `agentProxy.deliver` never race a concurrent rebuild. Each operation chains onto the tail, ensuring in-flight work completes before the agent is torn down.

### Exec Runner (`src/exec/runner.ts`)
Expand Down
10 changes: 9 additions & 1 deletion docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,13 +571,21 @@ Two mid-run gestures, two delivery times (CL-6290):
steer is queued so occupancy can deliver it. The transcript row says
`[will steer next]` while pending and
`[steering]` once delivered (`submitPrompt`, `drainSteersAtBoundary` in
`runtime-bridge.ts`).
`runtime-bridge.ts`). If the captured target agent is already closed when
delivery runs, the bridge restores the exact message (and attachments) to an
empty prompt, or FIFO-defers behind a draft the operator already typed — it
never auto-sends to a rebuilt successor. The transcript row is corrected to
`[not delivered]` (or `[delivery uncertain]` for non-closed failures), and
another explicit Enter is required before any new logical delivery.
- **Alt+Enter, mid-run** — follow-up: enqueues kind `"queue"` and delivers
only on **session-idle** (parent-idle and no live fleet lanes) as a `send`.
Does not interrupt or reinject. The transcript row says `[will follow up]`
while pending and `[following up]` once delivered. Idle, or with an empty
prompt, Alt+Enter does nothing — there is nothing to wait for. (Internal
`"reinject"` remains in the submit API for tests; no product chord wires it.)
Closed-target recovery for follow-ups uses the same prompt-restore / draft-
defer ownership as soft steer; `/clear`, `/new`, and dispose discard both
in-flight deliveries and deferred recoveries with the old session.

When `steer > 0` and a parent tool has been in flight ≥ `STEER_WAIT_NOTICE_MS`
(3s), the notice row adds `waiting on <tool>` (e.g. `waiting on run_shell`).
Expand Down
100 changes: 71 additions & 29 deletions src/tui/deliver-agent-message.test.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,100 @@
import { describe, expect, test } from "bun:test";
import { deliverAgentMessage } from "./deliver-agent-message.js";
import { AgentClosedError } from "@intx/agent";
import {
deliverAgentMessage,
deliveryResultNotice,
} from "./deliver-agent-message.js";

describe("deliverAgentMessage", () => {
test("surfaces a not-delivered notice instead of throwing when the agent is mid-rebuild", async () => {
const notices: string[] = [];
test("reports session-unavailable without calling deliver when rebuild failed", async () => {
const fatal = new Error("agent rebuild failed: provider unreachable");
let delivered = false;
let delivered = 0;

await deliverAgentMessage({
const result = await deliverAgentMessage({
getFatalBuildError: () => fatal,
deliverToLiveAgent: () => {
delivered = true;
delivered += 1;
},
onDeliverFailure: (message) => notices.push(message),
});

// The rebuild failed, so currentAgent still points at the closed agent.
// Delivery must never be attempted against it, and the operator must see
// why their message did not go through.
expect(delivered).toBe(false);
expect(notices).toHaveLength(1);
expect(notices[0]).toContain("not delivered");
expect(notices[0]).toContain("provider unreachable");
expect(delivered).toBe(0);
expect(result).toEqual({
status: "not-delivered",
reason: "session-unavailable",
detail: "agent rebuild failed: provider unreachable",
});
});

test("surfaces a not-delivered notice when the live agent throws on delivery", async () => {
const notices: string[] = [];
test("classifies typed AgentClosedError as agent-closed, not by message text", async () => {
let delivered = 0;

const result = await deliverAgentMessage({
getFatalBuildError: () => null,
deliverToLiveAgent: () => {
delivered += 1;
throw new AgentClosedError();
},
});

expect(delivered).toBe(1);
expect(result.status).toBe("not-delivered");
if (result.status === "not-delivered") {
expect(result.reason).toBe("agent-closed");
}
});

await deliverAgentMessage({
test("treats a plain Error whose message says agent is closed as uncertain", async () => {
const result = await deliverAgentMessage({
getFatalBuildError: () => null,
deliverToLiveAgent: () => {
throw new Error("agent is closed");
},
onDeliverFailure: (message) => notices.push(message),
});

expect(notices).toHaveLength(1);
expect(notices[0]).toContain("not delivered");
expect(notices[0]).toContain("agent is closed");
expect(result).toEqual({
status: "uncertain",
detail: "agent is closed",
});
});

test("delivers normally and stays silent when the agent is healthy", async () => {
const notices: string[] = [];
let delivered = false;
test("delivers once and returns accepted when the agent is healthy", async () => {
let delivered = 0;

await deliverAgentMessage({
const result = await deliverAgentMessage({
getFatalBuildError: () => null,
deliverToLiveAgent: () => {
delivered = true;
delivered += 1;
},
onDeliverFailure: (message) => notices.push(message),
});

expect(delivered).toBe(true);
expect(notices).toHaveLength(0);
expect(delivered).toBe(1);
expect(result).toEqual({ status: "accepted" });
});
});

describe("deliveryResultNotice", () => {
test("closed restored and deferred copy is actionable", () => {
const closed = {
status: "not-delivered" as const,
reason: "agent-closed" as const,
detail: "agent is closed",
};
expect(deliveryResultNotice(closed, "restored")).toBe(
"Message not delivered because the agent closed. It is back in the prompt; press Enter to send it.",
);
expect(deliveryResultNotice(closed, "deferred")).toBe(
"Message not delivered because the agent closed. Your current draft is unchanged; the message will return to the prompt after you send it.",
);
});

test("uncertain copy does not claim nondelivery", () => {
expect(
deliveryResultNotice(
{ status: "uncertain", detail: "network reset" },
"restored",
),
).toBe(
"Delivery failed: network reset. Delivery status is uncertain; review the transcript before sending again. It is back in the prompt; press Enter to send it.",
);
});
});
89 changes: 78 additions & 11 deletions src/tui/deliver-agent-message.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,95 @@
/**
* Guards a queued/steer deliver against a mid-rebuild agent. The shell paints
* the delivered row and pops the queue item before this runs, so a failure
* here must be surfaced — a swallowed error here means the transcript claims
* delivery for a message that never reached the agent.
* Guards a queued/steer deliver against a mid-rebuild or closed agent. The shell
* paints the delivered row and pops the queue item before this runs, so the
* caller must settle ownership from the structured result — a swallowed failure
* here means the transcript claims delivery for a message that never reached
* the agent.
*/
import { AgentClosedError } from "@intx/agent";

export type AgentDeliveryNotDeliveredReason =
| "agent-closed"
| "session-unavailable"
| "superseded"
| "preparation-failed";

export type AgentDeliveryResult =
| { readonly status: "accepted" }
| {
readonly status: "not-delivered";
readonly reason: AgentDeliveryNotDeliveredReason;
readonly detail: string;
}
| {
readonly status: "uncertain";
readonly detail: string;
};

export interface DeliverAgentMessageDeps {
getFatalBuildError: () => Error | null;
deliverToLiveAgent: () => void;
onDeliverFailure: (message: string) => void;
}

export async function deliverAgentMessage(
deps: DeliverAgentMessageDeps,
): Promise<void> {
): Promise<AgentDeliveryResult> {
const fatal = deps.getFatalBuildError();
if (fatal !== null) {
deps.onDeliverFailure(`Message not delivered: ${fatal.message}`);
return;
return {
status: "not-delivered",
reason: "session-unavailable",
detail: fatal.message,
};
}
try {
deps.deliverToLiveAgent();
return { status: "accepted" };
} catch (err) {
deps.onDeliverFailure(
`Message not delivered: ${err instanceof Error ? err.message : String(err)}`,
);
if (err instanceof AgentClosedError) {
return {
status: "not-delivered",
reason: "agent-closed",
detail: err.message,
};
}
return {
status: "uncertain",
detail: err instanceof Error ? err.message : String(err),
};
}
}

/** Operator-facing copy for a settled delivery that did not accept. */
export function deliveryResultNotice(
result: Exclude<AgentDeliveryResult, { status: "accepted" }>,
disposition: "restored" | "deferred" | "none" = "none",
): string {
if (result.status === "uncertain") {
const base = `Delivery failed: ${result.detail}. Delivery status is uncertain; review the transcript before sending again.`;
return appendDisposition(base, disposition);
}
if (result.reason === "agent-closed") {
if (disposition === "restored") {
return "Message not delivered because the agent closed. It is back in the prompt; press Enter to send it.";
}
if (disposition === "deferred") {
return "Message not delivered because the agent closed. Your current draft is unchanged; the message will return to the prompt after you send it.";
}
return "Message not delivered because the agent closed.";
}
const base = `Message not delivered: ${result.detail}`;
return appendDisposition(base, disposition);
}

function appendDisposition(
base: string,
disposition: "restored" | "deferred" | "none",
): string {
if (disposition === "restored") {
return `${base} It is back in the prompt; press Enter to send it.`;
}
if (disposition === "deferred") {
return `${base} Your current draft is unchanged; the message will return to the prompt after you send it.`;
}
return base;
}
2 changes: 2 additions & 0 deletions src/tui/gutter-labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const STORED_META_LITERALS = [
"following-up",
"reinject",
"cancelled",
"not-delivered",
"delivery-uncertain",
];

const FORBIDDEN = ["permission", "command", "overlay"];
Expand Down
6 changes: 4 additions & 2 deletions src/tui/live-session-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import type { PendingImageAttachment } from "./image-attachments.js";
import type { QueueItem, QueueKind } from "./session-queue.js";
import type { DeliverySettle } from "./queued-delivery.js";
import type { SessionPort } from "./runtime-bridge.js";

export type SubmitClassification = "agent" | "local" | "empty";
Expand All @@ -28,6 +29,7 @@ export interface LiveSessionPortDeps {
text: string,
kind: QueueKind,
attachments?: readonly PendingImageAttachment[],
settle?: DeliverySettle,
) => void;
}

Expand Down Expand Up @@ -56,8 +58,8 @@ export function createLiveSessionPort(deps: LiveSessionPortDeps): SessionPort {
interrupt: (): void => {
deps.interrupt();
},
deliver: (item: QueueItem): void => {
deps.deliver(item.text, item.kind, item.attachments);
deliver: (item: QueueItem, settle?: DeliverySettle): void => {
deps.deliver(item.text, item.kind, item.attachments, settle);
},
};
}
2 changes: 2 additions & 0 deletions src/tui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { hydrateHistoryRows } from "./history-hydrate.js";
import type { StreamRow } from "./stream.js";

import type { PendingImageAttachment } from "./image-attachments.js";
import type { DeliverySettle } from "./queued-delivery.js";

/** Suffix the row matching `activeId` (if any) so it reads as the current pick. */
function annotateCurrent(
Expand All @@ -93,6 +94,7 @@ export type ProductHostDeliver = (
text: string,
kind: QueueKind,
attachments?: readonly PendingImageAttachment[],
settle?: DeliverySettle,
) => void;

/**
Expand Down
Loading
Loading