diff --git a/src/tui/deliver-agent-message.test.ts b/src/tui/deliver-agent-message.test.ts index 904182c64..7a6a88243 100644 --- a/src/tui/deliver-agent-message.test.ts +++ b/src/tui/deliver-agent-message.test.ts @@ -4,7 +4,7 @@ import { deliverAgentMessage, deliveryResultNotice, runGenerationGuardedDeliver, -} from "./deliver-agent-message.js"; +} from "./delivery-queue.js"; describe("deliverAgentMessage", () => { test("reports session-unavailable without calling deliver when rebuild failed", async () => { diff --git a/src/tui/deliver-agent-message.ts b/src/tui/deliver-agent-message.ts deleted file mode 100644 index 0a70b3f07..000000000 --- a/src/tui/deliver-agent-message.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * 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; -} - -export async function deliverAgentMessage( - deps: DeliverAgentMessageDeps, -): Promise { - const fatal = deps.getFatalBuildError(); - if (fatal !== null) { - return { - status: "not-delivered", - reason: "session-unavailable", - detail: fatal.message, - }; - } - try { - deps.deliverToLiveAgent(); - return { status: "accepted" }; - } catch (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), - }; - } -} - -/** - * Settles a deliver that was enqueued on the serial operation queue against - * the shoot generation captured at enqueue time. The queue is FIFO with no - * preemption, so a deliver queued ahead of a reload still executes after the - * reload has replaced the agent — the generation must be re-checked when the - * queued closure runs, not just when it enqueues. A stale deliver takes the - * `onStale` path (the caller reports `not-delivered`); a current deliver runs - * the real settle. This is what closes the reload-vs-async-deliver race: a - * reload that lands while a continuation answer is queued wins, and the stale - * answer is dropped instead of reaching the replaced agent. - */ -export async function runGenerationGuardedDeliver(options: { - stillCurrent: () => boolean; - run: () => Promise; - onStale: () => AgentDeliveryResult; -}): Promise { - if (!options.stillCurrent()) { - return options.onStale(); - } - return options.run(); -} - -/** Operator-facing copy for a settled delivery that did not accept. */ -export function deliveryResultNotice( - result: Exclude, - 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; -} diff --git a/src/tui/delivery-queue.ts b/src/tui/delivery-queue.ts index 933a154cb..6e2f12114 100644 --- a/src/tui/delivery-queue.ts +++ b/src/tui/delivery-queue.ts @@ -17,12 +17,128 @@ * and no product chord wires it anymore — leave the path for tests/API only. */ +import { AgentClosedError } from "@intx/agent"; import type { PendingImageAttachment } from "./image-attachments.js"; -import type { AgentDeliveryResult } from "./deliver-agent-message.js"; import type { ProductHostDeliver } from "./product-host.js"; import { ASK_DIRECTOR_WAKE_PREFIX } from "../subagent/fleet-report.js"; import { MAILBOX_MAIL_WAKE_PREFIX } from "../subagent/mailbox-mail-drive.js"; +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; +} + +/** + * 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. + */ +export async function deliverAgentMessage( + deps: DeliverAgentMessageDeps, +): Promise { + const fatal = deps.getFatalBuildError(); + if (fatal !== null) { + return { + status: "not-delivered", + reason: "session-unavailable", + detail: fatal.message, + }; + } + try { + deps.deliverToLiveAgent(); + return { status: "accepted" }; + } catch (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), + }; + } +} + +/** + * Settles a deliver that was enqueued on the serial operation queue against + * the shoot generation captured at enqueue time. The queue is FIFO with no + * preemption, so a deliver queued ahead of a reload still executes after the + * reload has replaced the agent — the generation must be re-checked when the + * queued closure runs, not just when it enqueues. A stale deliver takes the + * `onStale` path (the caller reports `not-delivered`); a current deliver runs + * the real settle. This is what closes the reload-vs-async-deliver race: a + * reload that lands while a continuation answer is queued wins, and the stale + * answer is dropped instead of reaching the replaced agent. + */ +export async function runGenerationGuardedDeliver(options: { + stillCurrent: () => boolean; + run: () => Promise; + onStale: () => AgentDeliveryResult; +}): Promise { + if (!options.stillCurrent()) { + return options.onStale(); + } + return options.run(); +} + +/** Operator-facing copy for a settled delivery that did not accept. */ +export function deliveryResultNotice( + result: Exclude, + 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; +} + export type QueueKind = "queue" | "steer"; export interface QueueItem { diff --git a/src/tui/queued-delivery-hop.test.ts b/src/tui/queued-delivery-hop.test.ts index a21310cac..f3a0c9cc1 100644 --- a/src/tui/queued-delivery-hop.test.ts +++ b/src/tui/queued-delivery-hop.test.ts @@ -12,10 +12,10 @@ import { createLiveSteerDeliver, createSessionOperationQueue, routeQueuedDelivery, + type AgentDeliveryResult, type DeliverySettle, type QueueItem, } from "./delivery-queue.js"; -import type { AgentDeliveryResult } from "./deliver-agent-message.js"; function lastHopPort(bridgeRef: { current: SessionBridge | undefined }) { const sends: string[] = []; diff --git a/src/tui/queued-delivery.test.ts b/src/tui/queued-delivery.test.ts index de8e2d0b7..274afaab1 100644 --- a/src/tui/queued-delivery.test.ts +++ b/src/tui/queued-delivery.test.ts @@ -8,8 +8,8 @@ import { createSessionOperationQueue, routeQueuedDelivery, SESSION_IDENTITY_ABORT_REASON, + type AgentDeliveryResult, } from "./delivery-queue.js"; -import type { AgentDeliveryResult } from "./deliver-agent-message.js"; const image: PendingImageAttachment = { id: "img-1", diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 08e83de06..cf45941c3 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -79,6 +79,10 @@ import { createSessionCostAccumulator } from "../../cost/session-cost.js"; import { createDeliveryGeneration, createSessionOperationQueue, + deliverAgentMessage, + deliveryResultNotice, + runGenerationGuardedDeliver, + type AgentDeliveryResult, } from "../delivery-queue.js"; import { createCorrelationAcceptance } from "../correlation-acceptance.js"; import { createApprovalDeliverer } from "../approval-delivery.js"; @@ -92,12 +96,6 @@ import { detectLanguageServerAvailable } from "../../agent/lsp-availability.js"; import type { SessionMode } from "../../config/session-mode.js"; import { WorkflowHost, type WorkflowHostState } from "../../workflows/host.js"; import type { ToolWatchdogConfig } from "../tool-execution-watchdog.js"; -import { - deliverAgentMessage, - deliveryResultNotice, - runGenerationGuardedDeliver, - type AgentDeliveryResult, -} from "../deliver-agent-message.js"; import { createProviderFailureAttemptTracker } from "../provider/failure-attempt.js"; import { getTelemetry, liveTelemetry } from "../../telemetry/singleton.js"; import { diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index 42b0101b1..8f3dffa97 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -32,7 +32,7 @@ import type { ProviderFailureAttempt } from "../provider/failure-attempt.js"; import type { ScopedApproval } from "../../permission/admin.js"; import type { ConnectedMcpServer, RunState } from "../../session/state.js"; import type { PendingImageAttachment } from "../image-attachments.js"; -import type { AgentDeliveryResult } from "../deliver-agent-message.js"; +import type { AgentDeliveryResult } from "../delivery-queue.js"; import type { SubmitOutcome } from "./submit.js"; import type { mountRunnerHost } from "./host.js"; import { EventEmitter } from "node:events"; diff --git a/src/tui/runner/submit.ts b/src/tui/runner/submit.ts index f42c9b7f3..725d33782 100644 --- a/src/tui/runner/submit.ts +++ b/src/tui/runner/submit.ts @@ -35,8 +35,8 @@ import { createLeftoverSend, createLiveSteerDeliver, routeQueuedDelivery, + type AgentDeliveryResult, } from "../delivery-queue.js"; -import type { AgentDeliveryResult } from "../deliver-agent-message.js"; import type { InferenceAttemptIdentity } from "./state.js"; import { tuiSendFailureMessage } from "./send-failure-message.js"; import type { ProviderFailureAttempt } from "../provider/failure-attempt.js"; diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index a31276e39..d5ad1922f 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -72,7 +72,7 @@ import { import { deliveryResultNotice, type AgentDeliveryResult, -} from "./deliver-agent-message.js"; +} from "./delivery-queue.js"; import { toolCallRow } from "./diff.js"; import { toolResultRow } from "./mcp-view.js"; import {