diff --git a/crates/gjc-sdk/src/discovery.rs b/crates/gjc-sdk/src/discovery.rs index a06cc234c0d..31c4591c7c7 100644 --- a/crates/gjc-sdk/src/discovery.rs +++ b/crates/gjc-sdk/src/discovery.rs @@ -136,6 +136,15 @@ pub fn endpoint_path(state_root: &Path, session_id: &str) -> PathBuf { /// Propagates filesystem errors (permissions, disk, etc.). pub fn write_endpoint(state_root: &Path, record: &EndpointRecord) -> std::io::Result { let dir = endpoint_dir(state_root); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&dir)?; + } + #[cfg(not(unix))] fs::create_dir_all(&dir)?; harden_dir(&dir)?; @@ -371,6 +380,20 @@ mod tests { fs::remove_dir_all(&root).ok(); } + #[cfg(unix)] + #[test] + fn newly_created_endpoint_parents_are_private() { + use std::os::unix::fs::PermissionsExt; + let root = temp_root(); + let state_root = root.join(".gjc").join("state"); + let rec = EndpointRecord::new("sess-1", "127.0.0.1", 5555, "tok"); + write_endpoint(&state_root, &rec).unwrap(); + for dir in [root.join(".gjc"), state_root.clone(), endpoint_dir(&state_root)] { + assert_eq!(fs::metadata(dir).unwrap().permissions().mode() & 0o777, 0o700); + } + fs::remove_dir_all(root).unwrap(); + } + #[cfg(unix)] #[test] fn file_is_private_0600() { diff --git a/docs/sdk.md b/docs/sdk.md index 16ab928cab5..cace4972dcb 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -422,6 +422,54 @@ activity, or an earlier pending claim. Reconciliation state survives client disconnect/reconnect. With the session-private durable store (`.sdk-reconciliation/`), accepted and terminal prompt records also survive **GJC session-process restart** for the same session identity within capacity, subject to crash-consistent fsync. An ordinary non-terminal prompt record at restart finalizes its pending outcome and receipt state. A prompt with the explicit `deadlineRecoveryPending` marker is the exception: it remains `accepted` or `in_flight`, and its staged pending outcome is not exposed by Q26 while the SDK retains a durable recovery owner. A process restart does not recreate a missing exact-run/tool observation, so the pending outcome stays private until a real terminal event or new settlement evidence arrives. If ownership or settlement remains uncertain, the record stays nonterminal and recoverable instead of being converted into a synthetic deadline failure. A stopped prompt without receipt evidence becomes `terminal_ok + missing`; failed prompt or skill settlement without body evidence becomes `unknown`. Eviction or absence still returns honest `unknown`; that means the prior outcome is unknowable, not that execution did not occur. Active records are capped at 128 per kind and are never aged into terminal. Terminal records are capped at 256 per kind and evicted oldest-terminal first, with no age-based eviction. Reconciliation stores no prompt, transcript, credential, or provider-response body. +### ACP inline images and internal staging + +ACP clients can submit standard inline `ContentBlock.image` data on both initial +and follow-up prompts. GJC preserves the encoded image's original bytes and MIME +type; clients do not need a custom upload API or a `resource_link`. The internal +SDK frame cap remains 256 KiB. Large inline images cross it through SDK-core-owned +`turn.image.begin`, `turn.image.append`, `turn.image.finish`, and +`turn.image.discard` controls, not by increasing the cap or exposing endpoint +credentials to the ACP client. + +Staging is scoped to the authenticated live connection and session. Each upload +has a two-minute inactivity lease, a declared byte length, MIME type, and SHA-256 digest. +`turn.image.begin` optionally accepts a nonempty `batchId` of at most 128 characters. +Successful begin, append, and finish operations renew live uploads only for the same +authenticated connection and explicit batch; omitted batch IDs renew that upload alone. +ACP uses its request `clientRef` as the batch label so an early completed image stays +available while later images are still transferring. Invalid or rejected operations, +unrelated batches/connections, and cleanup do not renew these leases. Two minutes +without successful staging progress still retires the batch, without changing quotas. +Chunks are canonical base64 in sequence, with at most 96 KiB decoded per chunk. +The host verifies exact length, digest, MIME/header agreement, dimensions, and +image decoding before a reference is usable. A prompt accepts at most 16 images, +each at most 20 MiB, with 64 MiB source limits for pending and accepted images and +a shared 256 MiB process payload/copy budget. Appends coalesce into retained +96 KiB slabs instead of retaining one Buffer per fragment; tiny uploads reserve +at least 4 KiB. Session and process staging budgets charge the actual retained +allocation before it is created, and successful finalization releases slab +padding. Flexible fragment sizes remain supported without a chunk-count limit. +Expiry, discard, and connection loss retire unconsumed upload capacity. + +Finished references are connection-owned and one-shot. `turn.prompt` consumes +`stagedImages: [{ id }]`; callers cannot mix them with direct `images` or reuse +references after redemption, including a subsequent admission rejection. ACP's +bounded retry after a **confirmed** `busy` response restages the original bytes +with fresh references. Capacity rejection may precede redemption, so old IDs +must be discarded or confirmed already gone before fresh staging. Unconfirmed +cleanup emits a bounded diagnostic and refuses the retry; it does not invent +capacity release or replay an uncertain mutation. A lost or uncertain acknowledgement is reconciled through +`turn.result`, never treated as permission to upload and execute the prompt again. +The user's message is echoed once across a confirmed retry. Upload validation and +final staged-envelope bounds pass before any text/image echo is published, so an +upload rejection cannot leave an accepted-looking transcript entry. Validated replies +for this request's active staging renew only its local ACP inactivity watchdog; they +are not model/tool activity or execution authority. This pre-dispatch boundary does +not suppress the user message for later prompt-admission or admitted model failures. + +Cancellation applies to the exact outstanding prompt, including successor +admission while a cancelled image's user-message echo is still being published. ### Request-owned queue cancellation and execution deadlines SDK-only ordinary abort cancels a snapshot of its authenticated requester's @@ -449,6 +497,9 @@ progress in the same consuming run and cancellation domain. Session teardown retires joined attribution; late predecessor progress or terminal events cannot adopt or settle a successor. Transport or delivery failure alone does not prove execution settled and does not retire a live unsettled execution owner. +Accepted-image quota shares that exact run/domain ownership: transport diagnostics +or delivery-record expiry cannot release it. Only exact run terminal or session +teardown releases retained image capacity. `turn.prompt` remains ordered and non-idempotent. Its envelope `idempotencyKey` does not replay a response or produce `idempotency_conflict`. A retained duplicate @@ -471,8 +522,11 @@ accepted turn count — including a running tool's partial-result `tool_executio long-running tool that streams output (e.g. a multi-minute compile) keeps renewing the lease mid-run; heartbeats, streaming text/thinking deltas, retries, other turns/sessions, and unrelated session noise do not renew the lease, and out-of-order delivery never shortens it. The -hard maximum is never unbounded: every renewal is capped at `acceptedAt + sdk.promptMaxRuntimeMs` so a -wedged or continuously noisy prompt still reaches a deterministic terminal outcome. Terminalization then has a fixed `10_000` ms +hard maximum is never unbounded: every renewal is capped at the lease's start time plus +`sdk.promptMaxRuntimeMs`. The lease begins at acceptance for a directly executing prompt; +for confirmed queued input it is suspended during queue residence and begins again at +actual consumption or own-run promotion, without changing the durable `acceptedAt`. +A wedged or continuously noisy executing prompt still reaches a deterministic terminal outcome. Terminalization then has a fixed `10_000` ms grace period, which is not configurable. A controlled terminal failure reaches ACP as JSON-RPC `-32603` with `data.code` of `prompt_failed` or `prompt_deadline_exceeded`. diff --git a/packages/coding-agent/changelog.d/5879-stack-acp-images.md b/packages/coding-agent/changelog.d/5879-stack-acp-images.md new file mode 100644 index 00000000000..22849293b3b --- /dev/null +++ b/packages/coding-agent/changelog.d/5879-stack-acp-images.md @@ -0,0 +1,7 @@ +### Added +- Stage ACP inline prompt images larger than the private SDK frame limit through the authenticated SDK upload API without requiring Paseo-side uploads, while preserving original bytes and MIME and retaining the private 256 KiB full-frame limit. + +### Fixed +- Preserve cancelled user-image publication barriers across same-session-ID reattachment, including bounded failures during recordless recovery, retaining failed publication until explicit retirement; waiting successors remain locally cancellable without host aborts. +- Validate image staging and the final prompt envelope before publishing the user echo, and renew the local watchdog only for this request's validated staging progress; this guarantee does not cover later prompt admission or model failure. +- Keep upload progress, cancellation, socket handoff and echo-tail fences request-owned. Retry busy admission with fresh upload IDs only after confirmed retirement, and reconcile uncertain acknowledgements through the original client reference without mutation replay. diff --git a/packages/coding-agent/changelog.d/5879-stack-sdk-images.md b/packages/coding-agent/changelog.d/5879-stack-sdk-images.md new file mode 100644 index 00000000000..08bd7576c5c --- /dev/null +++ b/packages/coding-agent/changelog.d/5879-stack-sdk-images.md @@ -0,0 +1,17 @@ +### Added + +- Stage images on both standalone and notification SDK hosts using authenticated, one-shot upload references while preserving original bytes and MIME under the existing 256 KiB frame cap. +- Enforce pending, accepted and shared decode/copy allocation budgets; retain accepted capacity until exact consuming-run settlement or teardown, and prevent closed connection controls from recreating resources. +- Expire uploads after two minutes without successful staging progress; renew only live entries owned by the same authenticated connection and explicit batch, never rejected traffic or unrelated work. Enforce elapsed expiry even when cleanup callbacks are delayed, including redemption and in-flight finalization. + +### Fixed + +- Remove implicitly diverted queued images in both SDK hosts before acknowledging cancellation, and finalize each accepted image prompt at its exact consuming-run terminal or durably confirmed queue removal. +- Suspend image-prompt deadlines while queued, renew joined prompts only on their exact consuming run's progress, and preserve uncertainty when cancellation terminal persistence fails. +- Bound retained upload allocations for tiny image fragments as well as payload bytes. +- Cancel the SDK-only ordinary abort requester's admitted preflight snapshot through durable acceptance and before execution starts, without cancelling foreign or later admissions or inventing a durable terminal. +- Authenticate existing managed scope bindings and retained filesystem identities before cold cleanup recovery; never initialize or repair missing storage as a recovery shortcut. +- Re-certify a stale cleanup-completion digest only after independently verified completion through an exact descriptor-backed replacement; refuse destination swaps and replacement failures without replaying transcript deletion. +- Omit explicitly cleared optional owner fields from SDK cleanup replay decoding, matching their persisted JSON shape while retaining strict validation of present owner evidence and refusing replaced scope authority. +- Retain the originating SDK owner across todo and retry continuations, defer attempt-local failure diagnostics until the actual terminal, and keep real submission settlement behind its exact final publication; preserve acknowledged backoff cancellation without replaying a terminal or clearing committed failures, and keep resolving or rejected cleanup behind the actual durable-terminal recovery owner. +- Publish promoted SDK terminals after their captured handler and same-owner continuation decisions, and hold independent FIFO delivery behind that genuine public boundary without blocking the owning retry or todo continuation. diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 45613fe4f29..2e393b8777d 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import * as path from "node:path"; import { type Agent, @@ -36,7 +36,7 @@ import { type SetSessionModeRequest, type SetSessionModeResponse, } from "@agentclientprotocol/sdk"; -import { getAgentDir, logger, resolveEquivalentPath } from "@gajae-code/utils"; +import { getAgentDir, logger, resolveEquivalentPath, SUPPORTED_IMAGE_MIME_TYPES } from "@gajae-code/utils"; import packageJson from "../../../package.json" with { type: "json" }; import { ACP_SESSION_RECONNECT, @@ -65,10 +65,16 @@ import type { SdkPromptFailurePhase, SdkPromptTerminalOutcome, } from "../../sdk/prompt-status"; -import { PromptActivity, type PromptWatchdogClock, systemPromptWatchdogClock } from "../../sdk/prompt-watchdog"; +import { + ACP_PROMPT_INACTIVITY_TIMEOUT_MS, + PromptActivity, + type PromptWatchdogClock, + systemPromptWatchdogClock, +} from "../../sdk/prompt-watchdog"; import { validateRequiredPromptText } from "../../sdk/protocol/adapter-validation"; import { type SessionAttachment, SessionRouter, type SessionRouterFrame } from "../../sdk/router"; import { SessionListTraversalError, sessionListPageFromResponse, traverseSessionList } from "../../sdk/session-list"; +import { MAX_IMAGE_INPUT_BYTES, MAX_PASTED_IMAGE_SOURCE_BYTES } from "../../utils/image-limits"; import { resolveAcpAbortScope } from "./abort-scope"; import { type AgentSessionEvent, @@ -109,6 +115,10 @@ const CANCEL_SETTLEMENT_GRACE_MS = 5_000; * an oversize frame, so an over-limit prompt must be refused before it is sent. */ const MAX_PROMPT_FRAME_BYTES = 256 * 1024; +const IMAGE_UPLOAD_CHUNK_BYTES = 96 * 1024; +const MAX_PROMPT_IMAGES = 16; +const INVALID_IMAGE_BASE64_CHARACTER = /[^A-Za-z0-9+/]/; +const CANONICAL_TWO_BYTE_TAIL = "AEIMQUYcgkosw048"; /** * `SdkClient` wraps every control request as `{type,operation,input,id}` with a UUID * `id` before it reaches the socket, so the prompt must be measured inside that @@ -172,12 +182,18 @@ const ACP_MODEL_SETTLEMENT_TIMEOUT_MS = 500; type JsonObject = Record; interface PromptWaiter { + /** Stops pre-dispatch image work on cancellation, settlement, or attachment loss. */ + uploadAbort: AbortController; + /** User echo in flight; cancellation fences successor publication until it completes. */ + echoPublication?: Promise; acknowledged: boolean; invocationKind: "prompt" | "skill"; /** ACP-owned identity, never inherited from caller metadata or reused for replay. */ clientRef: string; - /** True once turn.prompt / skill.invoke has been sent; cancel must not fake-settle after this. */ + /** True after turn.prompt's synchronous socket send returns (or skill.invoke dispatch); host abort owns cancellation thereafter. */ dispatched: boolean; + /** While socket.send is synchronous and unresolved, cancellation must wait for its send/throw disposition. */ + dispatchPending?: Promise; /** True only while the dispatched control request can still reveal its correlation. */ acknowledgementPending: boolean; @@ -1737,6 +1753,8 @@ export class AcpAgent implements Agent { readonly #retiredPromptAcknowledgements = new Map(); /** Ready terminal metadata writes retain ownership across same-id record replacement. */ readonly #terminalMetadataTails = new Map>(); + /** Cancelled user publication and its failure survive same-id record replacement. */ + readonly #cancelledEchoTails = new Map>(); /** Unstreamed terminal text retains transcript ownership across same-id replacement. */ readonly #finalTextTails = new Map>(); /** Generic failure diagnostics publish independently from authoritative terminal ingress. */ @@ -2369,28 +2387,42 @@ export class AcpAgent implements Agent { if (record.pendingPromptAdmission) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt."); const activePrompt = record.activePrompt; - if (activePrompt) { - const cancellationPending = - record.cancelRequested || + const cancellationPending = + activePrompt !== undefined && + (record.cancelRequested || activePrompt.cancelAttempt !== undefined || - activePrompt.cancelAcknowledged === true; - if (!cancellationPending || record.pendingPromptAdmission) - throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt."); + activePrompt.cancelAcknowledged === true); + if (activePrompt && !cancellationPending) + throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt."); + if (cancellationPending || this.#cancelledEchoTails.has(params.sessionId)) { const { promise: woken, resolve: wake } = Promise.withResolvers(); admissionReservation = { wake, woken }; record.pendingPromptAdmission = admissionReservation; } + const admissionCancelled = (): boolean => { + const settlement = admissionReservation?.settlement; + if (settlement?.kind === "rejected") throw settlement.error; + return settlement?.kind === "cancelled" || admissionReservation?.cancelled === true; + }; try { if (admissionReservation) { - const settled = await this.#waitForPromptSettlement(activePrompt as PromptWaiter, admissionReservation); - const settlement = admissionReservation.settlement; - if (settlement?.kind === "rejected") throw settlement.error; - if (settlement?.kind === "cancelled" || admissionReservation.cancelled) return { stopReason: "cancelled" }; - if (!settled) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt."); - await this.#drainPromptPublicationTails(params.sessionId); - if (admissionReservation.settlement?.kind === "rejected") throw admissionReservation.settlement.error; - if (admissionReservation.settlement?.kind === "cancelled" || admissionReservation.cancelled) - return { stopReason: "cancelled" }; + if (activePrompt) { + const settled = await this.#waitForPromptSettlement(activePrompt, admissionReservation); + if (admissionCancelled()) return { stopReason: "cancelled" }; + if (!settled) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt."); + } + const publicationDrained = await Promise.race([ + this.#drainPromptPublicationTails(params.sessionId).then(() => true), + admissionReservation.woken.then(() => false), + ]); + if (admissionCancelled()) return { stopReason: "cancelled" }; + if (!publicationDrained) + throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt."); + const echoTail = this.#cancelledEchoTails.get(params.sessionId); + if (echoTail) { + await Promise.race([echoTail, admissionReservation.woken]); + if (admissionCancelled()) return { stopReason: "cancelled" }; + } } return await this.#submitPromptCore(params, echoUserMessage, retryReservation, admissionReservation); } finally { @@ -2466,6 +2498,31 @@ export class AcpAgent implements Agent { }); if (promptError) throw new AcpSdkAdapterError(promptError.code, promptError.message); } + // Check encoded lengths before serializing the complete SDK frame or allocating + // decoded buffers. The host applies these same bounds to staged source bytes. + if (payload.images.length > MAX_PROMPT_IMAGES) + throw new AcpSdkAdapterError("invalid_input", "ACP prompts cannot contain more than 16 images."); + let sourceBytes = 0; + for (const image of payload.images) { + if (!SUPPORTED_IMAGE_MIME_TYPES.has(image.mimeType)) + throw new AcpSdkAdapterError("invalid_input", "Unsupported ACP image MIME type."); + const data = image.data; + if (!data || data.length % 4 !== 0) + throw new AcpSdkAdapterError("invalid_input", "ACP image data must be canonical base64."); + const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0; + const byteLength = (data.length / 4) * 3 - padding; + if (byteLength <= 0 || byteLength > MAX_IMAGE_INPUT_BYTES) + throw new AcpSdkAdapterError("invalid_input", "ACP image exceeds the 20 MiB source limit."); + sourceBytes += byteLength; + if (sourceBytes > MAX_PASTED_IMAGE_SOURCE_BYTES) + throw new AcpSdkAdapterError("invalid_input", "ACP images exceed the 64 MiB source limit."); + if ( + data.search(INVALID_IMAGE_BASE64_CHARACTER) !== (padding ? data.length - padding : -1) || + (padding === 2 && !"AQgw".includes(data.at(-3)!)) || + (padding === 1 && !CANONICAL_TWO_BYTE_TAIL.includes(data.at(-2)!)) + ) + throw new AcpSdkAdapterError("invalid_input", "ACP image data must be canonical base64."); + } // A new turn starts uncancelled; a stale flag must never settle it as `cancelled`. record.cancelRequested = false; if (isAcpUnavailableSlashCommand(payload.text)) { @@ -2482,13 +2539,8 @@ export class AcpAgent implements Agent { ); return { stopReason: "end_turn" }; } - // The SDK transport hard-caps a single request frame at 256 KiB and answers an - // oversize frame by closing the socket (CloseCode::Size, crates/gjc-sdk/src/server.rs), - // which surfaces to the client as an opaque `connection_closed` mid-turn. Reject - // the prompt up front with a typed, actionable error instead of losing the session. - // Measure the frame the server actually receives, not just the payload: SdkClient - // wraps it as {type,operation,input,id} with a UUID id, so a prompt sized just - // under the cap would still be killed by CloseCode::Size. + // Measure the entire SDK frame, including its UUID envelope. Large image prompts + // use host-owned staging; text-only oversize prompts still fail before dispatch. const promptFrameBytes = Buffer.byteLength( JSON.stringify({ type: "control_request", @@ -2498,14 +2550,16 @@ export class AcpAgent implements Agent { // the field even though it is not part of the skill input payload. ...(skillInvocation ? { confirm: false } : {}), id: PROMPT_FRAME_ID_PLACEHOLDER, + ...(record.adapter.connectionId === undefined ? {} : { connectionId: record.adapter.connectionId }), }), ); - if (promptFrameBytes > MAX_PROMPT_FRAME_BYTES) + const stageImages = !skillInvocation && payload.images.length > 0 && promptFrameBytes > MAX_PROMPT_FRAME_BYTES; + if (promptFrameBytes > MAX_PROMPT_FRAME_BYTES && !stageImages) throw new AcpSdkAdapterError( "invalid_input", `ACP prompt is ${Math.ceil(promptFrameBytes / 1024)} KiB, over the ${Math.floor( MAX_PROMPT_FRAME_BYTES / 1024, - )} KiB transport limit. Attach a smaller or more compressed image.`, + )} KiB transport limit.`, ); record.publicationGeneration++; // Reset per attempt: a first-turn readiness retry keys on whether THIS attempt was @@ -2522,6 +2576,7 @@ export class AcpAgent implements Agent { const waiter: PromptWaiter = { invocationKind: skillInvocation ? "skill" : "prompt", clientRef, + uploadAbort: new AbortController(), acknowledged: false, dispatched: false, acknowledgementPending: false, @@ -2543,6 +2598,131 @@ export class AcpAgent implements Agent { resolve, reject, }; + const { promise: uploadStopped, resolve: stopUpload } = Promise.withResolvers(); + waiter.uploadAbort.signal.addEventListener("abort", () => stopUpload(undefined), { once: true }); + const stagedIds = new Set(); + const discardImage = async (id: string): Promise => { + try { + await record.adapter.uploadImageDiscard(id); + return true; + } catch (error) { + const code = + error instanceof SdkClientError || error instanceof AcpSdkAdapterError ? error.code : "unknown"; + if (code === "resource_gone") return true; + // A failed or uncertain cleanup stays host-owned until its inactivity lease expires + // or the connection closes. Preserve evidence without exposing image payloads. + logger.warn("acp_image_discard_failed", { + sessionId: params.sessionId, + code: /^[a-z][a-z0-9_]{0,63}$/.test(code) ? code : "unknown", + }); + return false; + } + }; + const discardStaged = async (): Promise => { + const pending = [...stagedIds].map(id => { + stagedIds.delete(id); + return discardImage(id); + }); + return (await Promise.all(pending)).every(confirmed => confirmed); + }; + waiter.uploadAbort.signal.addEventListener( + "abort", + () => { + void discardStaged(); + }, + { once: true }, + ); + // A request already on the wire cannot be cancelled. Fence each subsequent step + // and discard a late begin response instead of leaking its host lease. + const whileActive = async (task: Promise): Promise => { + const value = await Promise.race([task, settlement, uploadStopped]); + if (promptWaiterRetired(record, waiter) || waiter.uploadAbort.signal.aborted) + throw new AcpSdkAdapterError("prompt_cancelled", "ACP prompt stopped before image dispatch."); + return value as T; + }; + const observeUploadProgress = (): void => { + waiter.lastFrameAt = this.#promptWatchdogClock.now(); + waiter.lastFrameType = "image_upload_progress"; + this.#armPromptWatchdog(params.sessionId, record, waiter); + }; + const stagePromptImages = async (): Promise => { + for (const image of payload.images) { + const bytes = Buffer.from(image.data, "base64"); + if (bytes.toString("base64") !== image.data) + throw new AcpSdkAdapterError("invalid_input", "ACP image data must be canonical base64."); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const begin = record.adapter.uploadImageBegin({ + mimeType: image.mimeType, + byteLength: bytes.length, + sha256, + batchId: clientRef, + }); + void begin.then( + result => { + const id = result?.id; + if ( + typeof id === "string" && + id && + (promptWaiterRetired(record, waiter) || waiter.uploadAbort.signal.aborted) + ) + void discardImage(id); + }, + () => undefined, + ); + const { id, nextSequence } = await whileActive(begin); + if (typeof id === "string" && id) stagedIds.add(id); + if (typeof id !== "string" || !id || nextSequence !== 0) + throw new AcpSdkAdapterError( + "invalid_prompt_acknowledgement", + "SDK image begin acknowledgement is invalid.", + ); + observeUploadProgress(); + let sequence = 0; + for (let offset = 0; offset < bytes.length; offset += IMAGE_UPLOAD_CHUNK_BYTES) { + const chunk = bytes.subarray(offset, offset + IMAGE_UPLOAD_CHUNK_BYTES); + const appended = await whileActive( + record.adapter.uploadImageAppend({ id, sequence, data: chunk.toString("base64") }), + ); + if ( + appended?.id !== id || + appended.nextSequence !== ++sequence || + appended.receivedBytes !== offset + chunk.length + ) + throw new AcpSdkAdapterError( + "invalid_prompt_acknowledgement", + "SDK image append acknowledgement is invalid.", + ); + observeUploadProgress(); + } + const finished = await whileActive(record.adapter.uploadImageFinish(id)); + if ( + finished?.id !== id || + finished.byteLength !== bytes.length || + finished.sha256 !== sha256 || + finished.mimeType !== image.mimeType + ) + throw new AcpSdkAdapterError( + "invalid_prompt_acknowledgement", + "SDK image finish acknowledgement is invalid.", + ); + observeUploadProgress(); + } + const stagedFrameBytes = Buffer.byteLength( + JSON.stringify({ + type: "control_request", + operation: "turn.prompt", + id: PROMPT_FRAME_ID_PLACEHOLDER, + input: { + text: payload.text, + stagedImages: [...stagedIds].map(id => ({ id })), + clientRef, + }, + ...(record.adapter.connectionId === undefined ? {} : { connectionId: record.adapter.connectionId }), + }), + ); + if (stagedFrameBytes > MAX_PROMPT_FRAME_BYTES) + throw new AcpSdkAdapterError("invalid_input", "ACP prompt text exceeds the SDK transport limit."); + }; try { record.activePrompt = waiter; if (record.pendingPromptAdmission === admissionReservation) record.pendingPromptAdmission = undefined; @@ -2608,51 +2788,136 @@ export class AcpAgent implements Agent { // must be published verbatim so attachments are visible, not just fed to the model. // A first-turn readiness retry (issue #5574) skips the echo: the message was already // published on the first attempt and re-echoing would duplicate it in the transcript. - if (echoUserMessage) - for (const block of params.prompt) { - if (block.type !== "text" && block.type !== "image") continue; - if (block.type === "text" && block.text.length === 0) continue; - await this.#publishSessionUpdate( - params.sessionId, - { - sessionId: params.sessionId, - update: { sessionUpdate: "user_message_chunk", content: block }, - }, - record.adapter, - ); + let echoPending = false; + let echoTask: Promise | undefined; + try { + // Upload validation and final envelope bounds must pass before any transcript echo. + if (stageImages) await stagePromptImages(); + if (echoUserMessage) + for (const block of params.prompt) { + if (block.type !== "text" && block.type !== "image") continue; + if (block.type === "text" && block.text.length === 0) continue; + echoPending = true; + echoTask = this.#publishSessionUpdate( + params.sessionId, + { + sessionId: params.sessionId, + update: { sessionUpdate: "user_message_chunk", content: block }, + }, + record.adapter, + ); + waiter.echoPublication = echoTask; + await whileActive(echoTask); + echoPending = false; + echoTask = undefined; + waiter.echoPublication = undefined; + } + } catch (error) { + waiter.uploadAbort.abort(); + discardStaged(); + if (waiter.settled || record.activePrompt !== waiter) { + if (echoPending && !waiter.cancelAcknowledged && this.#sessions.get(params.sessionId) === record) + void this.#failSession( + params.sessionId, + record.adapter, + new AcpSdkAdapterError( + "connection_closed", + "ACP user-message publication did not complete before settlement.", + ), + ); + return await response; } + if (waiter.cancelAttempt && (await waiter.cancelAttempt)) { + await this.#settleCancelledPrompt(params.sessionId, record, waiter); + if (echoPending) + void this.#failSession( + params.sessionId, + record.adapter, + new AcpSdkAdapterError( + "connection_closed", + "ACP user-message publication did not complete before cancellation.", + ), + ); + return await response; + } + + record.activePrompt = undefined; + record.busy = record.backgroundBusy; + clearPromptWatchdog(waiter); + waiter.settled = true; + void this.#publishPromptPhaseIdle(params.sessionId, record.adapter); + throw error; + } if (waiter.settled || record.activePrompt !== waiter) { + discardStaged(); return await response; } if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) { + discardStaged(); await this.#settleCancelledPrompt(params.sessionId, record, waiter); return await response; } - waiter.dispatched = true; + if (skillInvocation) { + waiter.dispatched = true; + if (retryReservation) retryReservation.admitted = true; + } record.sdkIdle = false; - // The turn is now dispatched to the host: this prompt owned the session's first turn, - // so it — not a preflight rejection that threw before this point — is what settles - // `firstPromptDone` in `prompt()` (review P2). - if (retryReservation) retryReservation.admitted = true; waiter.acknowledgementPending = true; const promptAdapter = record.adapter; + let finishDispatch: (() => void) | undefined; const acknowledgementTask = (async (): Promise => { if (waiter.settled || record.activePrompt !== waiter) return await response; const submit = async (): Promise => skillInvocation ? await promptAdapter.control("skill.invoke", { ...skillInvocation, clientRef }) - : await promptAdapter.prompt({ - text: payload.text, - clientRef, - ...(payload.images.length ? { images: payload.images } : {}), - }); + : await promptAdapter.prompt( + { + text: payload.text, + clientRef, + ...(stageImages + ? { stagedImages: [...stagedIds].map(id => ({ id })) } + : payload.images.length + ? { images: payload.images } + : {}), + }, + context => { + if (promptWaiterRetired(record, waiter) || waiter.uploadAbort.signal.aborted) + throw new AcpSdkAdapterError( + "prompt_cancelled", + "ACP prompt stopped before image dispatch.", + ); + if (Buffer.byteLength(JSON.stringify(context.frame)) > MAX_PROMPT_FRAME_BYTES) + throw new AcpSdkAdapterError( + "invalid_input", + "ACP prompt exceeds the SDK transport limit.", + ); + const pending = Promise.withResolvers(); + waiter.dispatchPending = pending.promise; + finishDispatch = () => { + if (waiter.dispatchPending === pending.promise) waiter.dispatchPending = undefined; + pending.resolve(); + }; + }, + () => { + waiter.dispatched = true; + if (retryReservation) retryReservation.admitted = true; + finishDispatch?.(); + }, + ); let acknowledgement: unknown; try { acknowledgement = await submit(); } catch (error) { if (!(error instanceof SdkClientError || error instanceof AcpSdkAdapterError) || error.code !== "busy") throw error; + // The rejected attempt owns no host execution. Cancellation while waiting or + // restaging must stay local rather than abort an unrelated active run. + waiter.dispatched = false; + // Capacity rejection can precede one-shot redemption. Retire old references + // before restaging; only acknowledged discard/already-gone proves cleanup. + if (stageImages && !(await whileActive(discardStaged()))) + throw new AcpSdkAdapterError("image_cleanup_failed", "Image staging cleanup was not confirmed."); if ( record.activePrompt !== waiter || waiter.settled || @@ -2671,6 +2936,7 @@ export class AcpAgent implements Agent { waiter.cancelBeforeAdmission ) throw error; + if (stageImages) await stagePromptImages(); acknowledgement = await submit(); } // A recovered waiter already owns its exact identity; a late ack cannot rebind it. @@ -2682,6 +2948,7 @@ export class AcpAgent implements Agent { "invalid_prompt_acknowledgement", "SDK prompt acknowledgement must accept the prompt and include commandId and turnId.", ); + stagedIds.clear(); // The host copied and consumed the images before acknowledging. if ( this.#sessions.get(params.sessionId) !== record || record.adapter !== promptAdapter || @@ -2903,6 +3170,11 @@ export class AcpAgent implements Agent { return await response; })() .catch(async error => { + if (error instanceof SdkClientError && error.code === "uncertain_after_send") { + waiter.dispatched = true; + if (retryReservation) retryReservation.admitted = true; + } + finishDispatch?.(); if ( !(error instanceof SdkClientError || error instanceof AcpSdkAdapterError) || error.code !== "uncertain_after_send" @@ -2913,6 +3185,8 @@ export class AcpAgent implements Agent { return await response; }) .finally(() => { + finishDispatch?.(); + discardStaged(); waiter.acknowledgementPending = false; this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter); }); @@ -2968,15 +3242,65 @@ export class AcpAgent implements Agent { if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`); // Record the client's intent before awaiting the SDK so a prompt that rejects // mid-cancel (e.g. preflight `busy`) can still settle as `cancelled`. + const pendingAdmission = record.pendingPromptAdmission; record.cancelRequested = true; this.#settlePendingPromptAdmission(record, { kind: "cancelled" }); + const waiter = record.activePrompt; + if (pendingAdmission && !waiter) { + record.cancelRequested = false; + return; + } + waiter?.uploadAbort.abort(); + // A reentrant cancel inside socket.send cannot decide whether the frame was + // accepted until onDispatch or the synchronous send failure is observed. + if (waiter?.dispatchPending) await waiter.dispatchPending; + // Nothing has reached turn.prompt yet: the upload belongs entirely to this + // ACP request, and a host terminal abort could stop an unrelated turn. + if (waiter && !waiter.dispatched) { + if (waiter.echoPublication) { + const { promise: tail, resolve, reject } = Promise.withResolvers(); + let failed = false; + const failEcho = (message: string) => { + if (this.#cancelledEchoTails.get(params.sessionId) !== tail) return; + failed = true; + const error = new AcpSdkAdapterError("connection_closed", message); + const current = this.#sessions.get(params.sessionId); + if (current) void this.#failSession(params.sessionId, current.adapter, error); + reject(error); + }; + const cancelDeadline = this.#promptWatchdogClock.schedule( + () => + failEcho( + "ACP cancelled user-message publication did not complete before the prompt inactivity bound.", + ), + ACP_PROMPT_INACTIVITY_TIMEOUT_MS, + ); + void waiter.echoPublication.then( + () => { + cancelDeadline(); + if (failed) return; + if (this.#cancelledEchoTails.get(params.sessionId) === tail) + this.#cancelledEchoTails.delete(params.sessionId); + resolve(); + }, + () => { + cancelDeadline(); + failEcho("ACP cancelled user-message publication failed."); + }, + ); + this.#cancelledEchoTails.set(params.sessionId, tail); + void tail.catch(() => undefined); + } + waiter.cancelAcknowledged = true; + await this.#settleCancelledPrompt(params.sessionId, record, waiter); + return; + } // C04 terminal abort: an external client cancel stops the current turn // (`scope:"turn"`, the default, matching the SDK `turn.abort` default and // other ACP clients' cancel behavior). A client that also wants exact owned // subagents and background tasks stopped opts in with // `_meta.gjc.abortScope: "owned"` (or `GJC_ACP_ABORT_SCOPE=owned`). const scope = resolveAcpAbortScope(params._meta, process.env); - const waiter = record.activePrompt; const waiterWasUnacknowledged = waiter !== undefined && !waiter.acknowledged; const waiterWasBeforeActivity = waiter !== undefined && !waiter.observedTurnActivity; if (waiter) { @@ -4180,6 +4504,7 @@ export class AcpAgent implements Agent { async #teardownSession(id: string, reason: string, closeRemote: boolean): Promise { const record = this.#sessions.get(id); const ownershipBound = record !== undefined || this.#ownedSessionIds.has(id); + const cancelledEchoTail = this.#cancelledEchoTails.get(id); this.#beginTeardown(id); try { this.#advanceSessionEpoch(id); @@ -4268,6 +4593,11 @@ export class AcpAgent implements Agent { this.#pendingCloseIdempotencyKeys.delete(id); this.#uncertainAbortOwners.delete(id); } + if ( + (reason === "closed" || reason === "discarded" || reason === "deleted") && + this.#cancelledEchoTails.get(id) === cancelledEchoTail + ) + this.#cancelledEchoTails.delete(id); } finally { this.#finishTeardown(id); } @@ -6194,6 +6524,7 @@ export class AcpAgent implements Agent { this.#retiredPromptCorrelations.clear(); this.#retiredPromptAcknowledgements.clear(); this.#terminalMetadataTails.clear(); + this.#cancelledEchoTails.clear(); this.#finalTextTails.clear(); this.#failureDiagnosticTails.clear(); this.#promptPhaseTails.clear(); diff --git a/packages/coding-agent/src/sdk/acp/adapter.ts b/packages/coding-agent/src/sdk/acp/adapter.ts index 347a0bac410..4536db31cd6 100644 --- a/packages/coding-agent/src/sdk/acp/adapter.ts +++ b/packages/coding-agent/src/sdk/acp/adapter.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { logger } from "@gajae-code/utils"; import { lifecycleRequestTimeoutMs } from "../broker/startup-budget"; -import { type SdkClient, SdkClientError } from "../client"; +import { type SdkClient, SdkClientError, type SdkDispatchContext } from "../client"; import type { AbortScope } from "../host/control/operations"; import { assertReverseResponseFrame, ReverseLeaseError } from "../host/reverse-leases"; import { @@ -438,7 +438,11 @@ export class AcpSdkAdapter { if (this.#client) await this.#client.close(); } - async prompt(params: JsonObject | string): Promise { + async prompt( + params: JsonObject | string, + beforeDispatch?: (context: SdkDispatchContext) => void, + onDispatch?: (context: SdkDispatchContext) => void, + ): Promise { const rawText = typeof params === "string" ? params @@ -449,13 +453,57 @@ export class AcpSdkAdapter { const invalid = validateRequiredPromptText("turn.prompt", { text, ...(typeof params === "object" && Array.isArray(params.images) ? { images: params.images } : {}), + ...(typeof params === "object" && Array.isArray(params.stagedImages) + ? { stagedImages: params.stagedImages } + : {}), }); if (invalid) throw new AcpSdkAdapterError(invalid.code, invalid.message); - return await this.#requestSession({ - type: "control_request", - operation: "turn.prompt", - input: { ...(typeof params === "object" ? params : {}), text }, - }); + return await this.#requestSession( + { + type: "control_request", + operation: "turn.prompt", + input: { ...(typeof params === "object" ? params : {}), text }, + }, + false, + { beforeDispatch, onDispatch }, + ); + } + /** Machine-origin upload controls; never route these through the public control() disposition. */ + async uploadImageBegin(input: { + mimeType: string; + byteLength: number; + sha256: string; + batchId?: string; + }): Promise<{ id: string; nextSequence: number }> { + return (await this.#requestImageUpload("turn.image.begin", input)) as { + id: string; + nextSequence: number; + }; + } + async uploadImageAppend(input: { + id: string; + sequence: number; + data: string; + }): Promise<{ id: string; nextSequence: number; receivedBytes: number }> { + return (await this.#requestImageUpload("turn.image.append", input)) as { + id: string; + nextSequence: number; + receivedBytes: number; + }; + } + async uploadImageFinish(id: string): Promise<{ id: string; byteLength: number; sha256: string; mimeType: string }> { + return (await this.#requestImageUpload("turn.image.finish", { id })) as { + id: string; + byteLength: number; + sha256: string; + mimeType: string; + }; + } + async uploadImageDiscard(id: string): Promise { + await this.#requestImageUpload("turn.image.discard", { id }); + } + async #requestImageUpload(operation: string, input: JsonObject): Promise { + return await this.#requestSession({ type: "control_request", operation, input }); } /** * Ends the active turn with a C04 terminal abort. The default `scope:"turn"` @@ -531,7 +579,15 @@ export class AcpSdkAdapter { return envelope?.result ?? response; } - async #requestSession(frame: JsonObject, raw = false, options?: { timeoutMs: number }): Promise { + async #requestSession( + frame: JsonObject, + raw = false, + options?: { + timeoutMs?: number; + beforeDispatch?: (context: SdkDispatchContext) => void; + onDispatch?: (context: SdkDispatchContext) => void; + }, + ): Promise { const router = this.#router; if (!router) throw new AcpSdkAdapterError( diff --git a/packages/coding-agent/src/sdk/broker/lifecycle.ts b/packages/coding-agent/src/sdk/broker/lifecycle.ts index 101a5c771b3..ac5ce5a7260 100644 --- a/packages/coding-agent/src/sdk/broker/lifecycle.ts +++ b/packages/coding-agent/src/sdk/broker/lifecycle.ts @@ -994,7 +994,7 @@ type CleanupEvidence = BrokerCleanupEvidence; function brokerTaskArtifactOwnerCleanupFields(cleanup: CleanupEvidence): Record { const fields: Record = {}; for (const [key, value] of Object.entries(cleanup)) { - if (key.startsWith("taskArtifactOwner")) fields[key] = value; + if (key.startsWith("taskArtifactOwner") && value !== undefined) fields[key] = value; } return fields; } diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index 6ce09068ad3..31156acbb83 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -122,6 +122,7 @@ import { import { type AbortScope, type ControlSurface, dispatchControl, TypedControlError } from "../host/control"; import { BROKER_RUNTIME_CLOSE_CAPABILITY_FIELD } from "../host/control/runtime-gate"; import { isAutoroutingInactive, markAutoroutingInactive } from "../host/internal-autorouting-state"; +import { PromptImageUploadStore } from "../host/prompt-image-upload"; import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query"; import { RESPONSE_CEILING_BYTES } from "../host/query/handlers"; import type { SdkFrame } from "../host/types"; @@ -1235,8 +1236,12 @@ export class PresentationArbiter { interface SessionRuntime { server: NotificationServer; host: SessionSdkHost; + imageUploads: PromptImageUploadStore; + releaseAcceptedImage: (correlation: { commandId: string; turnId: string }) => void; + releaseAcceptedImagesForRun: (owner: AgentTerminalOwnerContext) => void; getJoinedPromptCorrelations: (owner: AgentTerminalOwnerContext) => Array<{ commandId: string; turnId: string }>; isPromptRunOwner: (correlation: { commandId: string; turnId: string }, owner: AgentTerminalOwnerContext) => boolean; + releaseAcceptedImages: () => void; retireJoinedPromptOwners: () => void; /** Delivers one ring-positioned event envelope to every attached subscriber * connection, applying the same capability gate as event replay. */ @@ -2612,6 +2617,9 @@ function sdkControlSurface( pendingInteractive: Map, gatePresentations: PresentationArbiter | undefined, api: ExtensionAPI, + imageUploads: PromptImageUploadStore, + retainAcceptedImage: (correlation: { commandId: string; turnId: string }, release: () => void) => void, + releaseAcceptedImage: (correlation: { commandId: string; turnId: string }) => void, isBusy: () => boolean, onPromptAccepted: ( correlation: { commandId: string; turnId: string }, @@ -2908,6 +2916,7 @@ function sdkControlSurface( requesterConnectionId?: string, clientRef?: string, trackReconciliation = false, + onCorrelation?: (correlation: { commandId: string; turnId: string }) => void, ) => { const trimmedClientRef = typeof clientRef === "string" ? clientRef.trim() : undefined; if (clientRef !== undefined && (!trimmedClientRef || trimmedClientRef.length > PROMPT_CLIENT_REF_MAX_LENGTH)) @@ -2970,6 +2979,7 @@ function sdkControlSurface( : text; const commandId = crypto.randomUUID(); const turnId = crypto.randomUUID(); + onCorrelation?.({ commandId, turnId }); const sdkRunToken = deliverAs === "followUp" ? crypto.randomUUID() : undefined; type PreflightTerminalResult = { status: "accepted" } | { status: "rejected"; error: unknown }; const preflight = Promise.withResolvers(); @@ -3063,6 +3073,7 @@ function sdkControlSurface( onQueuedPromoted: promotion => { admission.hooks.onQueuedPromoted(promotion); onPromptPromoted(correlation, promotion, ctx.getActivePromptHandle()); + if (promotion.removed) releaseAcceptedImage(correlation); }, onDispatchDisposition: disposition => { admission.hooks.onDispatchDisposition(disposition); @@ -3120,8 +3131,39 @@ function sdkControlSurface( cancelPendingPreflights(): Promise; cancelPendingPreflightsForConnection(connectionId: string): Promise; } = { - prompt: (text, images, clientRef) => - submitPrompt(text, images, false, undefined, true, controlRequesterContext.getStore(), clientRef, true), + prompt: async (text, images, clientRef, stagedImages) => { + if (stagedImages !== undefined && images !== undefined) + throw Object.assign(new Error("Direct and staged images cannot be mixed."), { code: "invalid_input" }); + const reservation = + stagedImages === undefined + ? undefined + : imageUploads.redeem(controlRequesterContext.getStore(), stagedImages); + let correlation: { commandId: string; turnId: string } | undefined; + try { + return await submitPrompt( + text, + reservation?.images ?? images, + false, + undefined, + true, + controlRequesterContext.getStore(), + clientRef, + true, + current => { + correlation = current; + if (reservation) retainAcceptedImage(current, reservation.release); + }, + ); + } catch (error) { + if (correlation) releaseAcceptedImage(correlation); + else reservation?.release(); + throw error; + } + }, + imageBegin: input => imageUploads.begin(controlRequesterContext.getStore(), input), + imageAppend: input => imageUploads.append(controlRequesterContext.getStore(), input), + imageFinish: input => imageUploads.finish(controlRequesterContext.getStore(), input), + imageDiscard: input => imageUploads.discard(controlRequesterContext.getStore(), input), steer: (text, clientRef) => sendSteer(text, clientRef), followUp: text => submitPrompt(text, undefined, false, "followUp", false, controlRequesterContext.getStore()), abort: async () => { @@ -4602,6 +4644,8 @@ export function createNotificationsExtension( if (reason === "session" && requestedRuntime) { requestedRuntime.inboundFenced = true; requestedRuntime.stopping = true; + requestedRuntime.imageUploads.close(); + requestedRuntime.releaseAcceptedImages(); requestedRuntime.retireJoinedPromptOwners(); requestedRuntime.abortEphemeralTurns(); } @@ -4871,6 +4915,15 @@ export function createNotificationsExtension( return failLifecycleStartup("failed", "Lifecycle SDK startup requires an agent directory."); const pendingInteractive = new Map(); + // Image controls may leave the ordered queue after their socket closes. + // Resolve liveness at execution against the host's connection incarnation; + // this map is initialized before any frame can invoke the callback. + const imageUploads = new PromptImageUploadStore(connectionId => { + const incarnation = hostConnectionIncarnations.get(connectionId); + return incarnation !== undefined && !incarnation.closed; + }); + const acceptedImages = new Map void>(); + const acceptedImageRunOwners = new Map(); const queuedRemovalTerminals = new Map>(); const joinedPromptOwners = new Map< string, @@ -4883,6 +4936,20 @@ export function createNotificationsExtension( owner.resourceRunId === terminalOwner.resourceRunId && owner.domain === terminalOwner.domain, ) .map(({ correlation }) => correlation); + const releaseAcceptedImage = (correlation: { commandId: string; turnId: string }) => { + const key = `${correlation.commandId}:${correlation.turnId}`; + acceptedImages.get(key)?.(); + acceptedImages.delete(key); + acceptedImageRunOwners.delete(key); + }; + const releaseAcceptedImagesForRun = (terminalOwner: AgentTerminalOwnerContext) => { + for (const [key, owner] of acceptedImageRunOwners) { + if (owner.resourceRunId !== terminalOwner.resourceRunId || owner.domain !== terminalOwner.domain) continue; + acceptedImages.get(key)?.(); + acceptedImages.delete(key); + acceptedImageRunOwners.delete(key); + } + }; const retireJoinedPromptOwners = (): void => joinedPromptOwners.clear(); const pendingPromptCorrelations: Array<{ commandId: string; turnId: string }> = []; const pendingPromptCorrelationsBySdkRunToken = new Map(); @@ -6196,6 +6263,9 @@ export function createNotificationsExtension( } submission.executionHandle = handle; submission.preflightAbort = undefined; + const domain = handle ? terminalAbortSeams?.getRunOwnerDomain?.(handle) : undefined; + if (handle && domain && acceptedImages.has(key)) + acceptedImageRunOwners.set(key, { resourceRunId: handle, domain }); } }; const onPromptDiverted = (correlation: { commandId: string; turnId: string }) => { @@ -6489,6 +6559,7 @@ export function createNotificationsExtension( if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer); if (!recordPromptTerminal(correlation)) return; joinedPromptOwners.delete(promptSubmissionKey(correlation)); + releaseAcceptedImage(correlation); if (!runtime) { // The runtime (and with it the positioned ring) is gone, so the // terminal can never be published from this process; expire the @@ -6663,6 +6734,9 @@ export function createNotificationsExtension( pendingInteractive, gatePresentations, api, + imageUploads, + (correlation, release) => acceptedImages.set(`${correlation.commandId}:${correlation.turnId}`, release), + releaseAcceptedImage, () => runtime?.busy === true || pendingPromptCorrelations.length > 0 || @@ -8157,11 +8231,19 @@ export function createNotificationsExtension( runtime = { server, host, + imageUploads, + releaseAcceptedImage, + releaseAcceptedImagesForRun, getJoinedPromptCorrelations, isPromptRunOwner: (correlation, owner) => { const handle = promptSubmissions.get(promptSubmissionKey(correlation))?.executionHandle; return handle === owner.resourceRunId && terminalAbortSeams?.getRunOwnerDomain?.(handle) === owner.domain; }, + releaseAcceptedImages: () => { + for (const release of acceptedImages.values()) release(); + acceptedImages.clear(); + acceptedImageRunOwners.clear(); + }, retireJoinedPromptOwners, broadcastEventFrame, broadcastEventFrameWithReceipts, @@ -8415,8 +8497,15 @@ export function createNotificationsExtension( sendEndpointStale(inbound.connectionId, typedFrame); return; } + // The native callback supplies the authenticated socket identity. A + // control may be the first frame, before capability negotiation or replay. + // Admit that live socket once; a closed incarnation cannot be revived. + if (!liveHostConnection(inbound.connectionId)) { + sendEndpointStale(inbound.connectionId, typedFrame); + return; + } if (typedFrame.type === "event_replay") { - if (liveHostConnection(inbound.connectionId)) hostAttachedConnections.add(inbound.connectionId); + hostAttachedConnections.add(inbound.connectionId); } if (typedFrame.type === "ephemeral_turn" || typedFrame.type === "ephemeral_turn_cancel") return; inboundSdkFrame?.(inbound.connectionId, typedFrame); @@ -8468,6 +8557,7 @@ export function createNotificationsExtension( server.onConnectionClose((_err, connectionId) => { if (!connectionId) return; closeHostConnection(connectionId); + imageUploads.disconnect(connectionId); connectionCloseHandler?.(connectionId); void controlSurface .cancelPendingPreflightsForConnection(connectionId) @@ -10066,9 +10156,11 @@ export function createNotificationsExtension( const id = sessionId(ctx); const rt = runtimes.get(id); if (!rt) return; - // The Agent's terminal owner is independent of delivery correlation. Never - // borrow a successor's run handle to settle a predecessor's SDK prompts. + // The Agent's terminal owner is independent of delivery correlation: a + // failed transport may have cleared the latter while the original run + // still owned image strings. Never borrow a successor's run handle. const terminalOwner = terminalAbortSeams?.getTerminalRunOwnerForEvent?.(event); + if (terminalOwner) rt.releaseAcceptedImagesForRun(terminalOwner); const rootCorrelation = rt.activePromptCorrelation; const correlations = terminalOwner ? rt.getJoinedPromptCorrelations(terminalOwner) : []; if ( @@ -10083,8 +10175,9 @@ export function createNotificationsExtension( for (const correlation of correlations) { // This attributed agent_end is an execution boundary even when the // subsequent durable terminal claim fails. A fatal transport closure - // clears attribution, so an unrelated later agent_end cannot settle a - // predecessor's prompt correlation. + // clears attribution, so an unrelated later agent_end cannot release + // reservations for a run whose settlement was never proved. + rt.releaseAcceptedImage(correlation); const assistants = (Array.isArray(event.messages) ? [...event.messages].reverse() : []).filter( message => message && typeof message === "object" && (message as { role?: unknown }).role === "assistant", ) as Array<{ stopReason?: unknown; errorKind?: unknown; errorCode?: unknown }>; diff --git a/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts b/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts index 630d615118d..085859803f2 100644 --- a/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts +++ b/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts @@ -297,12 +297,14 @@ export const SDK_LIFECYCLE_ROUTER_PROTOCOL_VERSION = 1; * Generation 193 gates streamed content on negotiated observer capabilities, * so existing owners are replaced before exposing the new observer path. * Generation 201 binds queued cancellation and terminal deadlines to the exact - * consuming run, so pre-fix owners cannot serve the new generic lifecycle path. - * Generation 202 joins queued-removal terminal publication before ordinary - * abort acknowledgement, replacing owners that still acknowledge cancellation - * before its terminal record is durable. + * consuming run, so pre-fix owners cannot serve the generic lifecycle path. + * Generation 202 adds complete image staging and exact accepted-capacity release + * when queued submissions are removed, replacing generation-201 image-free owners. + * Generation 203 joins queued-removal terminal publication before ordinary + * abort acknowledgement and shutdown image-capacity release, replacing either + * image-free cancellation owners or image owners that acknowledge too early. */ -export const DAEMON_GENERATION = 202; +export const DAEMON_GENERATION = 203; /** * Serving-compatibility boundary for daemon lifecycle requests. Epoch 7 diff --git a/packages/coding-agent/src/sdk/host/control/dispatch.ts b/packages/coding-agent/src/sdk/host/control/dispatch.ts index a15792f5215..8c138a2632c 100644 --- a/packages/coding-agent/src/sdk/host/control/dispatch.ts +++ b/packages/coding-agent/src/sdk/host/control/dispatch.ts @@ -256,7 +256,19 @@ function invoke( ): Promise | ControlValue { switch (operation) { case "turn.prompt": - return surface.prompt(text(input), input.images, input.clientRef as string | undefined); + return surface.prompt(text(input), input.images, input.clientRef as string | undefined, input.stagedImages); + case "turn.image.begin": + if (!surface.imageBegin) invalidInput("Image uploads are not installed for this session."); + return surface.imageBegin(input); + case "turn.image.append": + if (!surface.imageAppend) invalidInput("Image uploads are not installed for this session."); + return surface.imageAppend(input); + case "turn.image.finish": + if (!surface.imageFinish) invalidInput("Image uploads are not installed for this session."); + return surface.imageFinish(input); + case "turn.image.discard": + if (!surface.imageDiscard) invalidInput("Image uploads are not installed for this session."); + return surface.imageDiscard(input); case "turn.steer": return surface.steer( text(input), diff --git a/packages/coding-agent/src/sdk/host/control/operations.ts b/packages/coding-agent/src/sdk/host/control/operations.ts index 7013e13f6b3..09c12f76cd9 100644 --- a/packages/coding-agent/src/sdk/host/control/operations.ts +++ b/packages/coding-agent/src/sdk/host/control/operations.ts @@ -21,7 +21,16 @@ export type ControlInput = Record; * AgentSession and its controllers without exposing those concrete types here. */ export interface ControlSurface { - prompt(text: string, images?: ControlValue, clientRef?: string): Promise | ControlValue; + prompt( + text: string, + images?: ControlValue, + clientRef?: string, + stagedImages?: ControlValue, + ): Promise | ControlValue; + imageBegin?(input: ControlValue): Promise | ControlValue; + imageAppend?(input: ControlValue): Promise | ControlValue; + imageFinish?(input: ControlValue): Promise | ControlValue; + imageDiscard?(input: ControlValue): Promise | ControlValue; steer(text: string, clientRef?: string, expectedSdkRunToken?: string): Promise | ControlValue; followUp(text: string): Promise | ControlValue; abort(): Promise | ControlValue; diff --git a/packages/coding-agent/src/sdk/host/prompt-image-upload.ts b/packages/coding-agent/src/sdk/host/prompt-image-upload.ts new file mode 100644 index 00000000000..963fc9dc26d --- /dev/null +++ b/packages/coding-agent/src/sdk/host/prompt-image-upload.ts @@ -0,0 +1,380 @@ +import * as crypto from "node:crypto"; +import type { ImageContent } from "@gajae-code/ai/core"; +import { parseImageMetadata } from "@gajae-code/utils"; +import { + MAX_IMAGE_INPUT_BYTES, + MAX_PASTED_IMAGE_DIMENSION, + MAX_PASTED_IMAGE_PIXELS, + MAX_PASTED_IMAGE_SOURCE_BYTES, +} from "../../utils/image-limits"; +import { TypedControlError } from "./control/dispatch"; + +const MAX_CHUNK_BYTES = 96 * 1024; +const MIN_RETAINED_BUFFER_BYTES = 4 * 1024; +const MAX_IMAGES = 16; +const MAX_UPLOADS = 16; +const MAX_ACCEPTED_BYTES = 64 * 1024 * 1024; +const MAX_PROCESS_BYTES = 256 * 1024 * 1024; +const MAX_CONCURRENT_FINISHES = 1; +const LEASE_MS = 120_000; +const MAX_BATCH_ID_LENGTH = 128; +const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +let processBytes = 0; +let transientBytes = 0; +let concurrentFinishes = 0; + +type Upload = { + owner: string; + batchId?: string; + mimeType: string; + byteLength: number; + sha256: string; + chunks: Buffer[]; + reservedBytes: number; + length: number; + sequence: number; + finishing: boolean; + finished: boolean; + expiresAt: number; + timer: NodeJS.Timeout; +}; + +function invalid(message: string): never { + throw new TypedControlError("invalid_input", message); +} +function busy(message: string): never { + throw new TypedControlError("busy", message); +} +function object(value: unknown, fields: readonly string[]): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) invalid("Image input must be an object."); + const input = value as Record; + if (Object.keys(input).some(key => !fields.includes(key))) invalid("Unknown image input field."); + return input; +} +function ownerId(connectionId: string | undefined): string { + if (!connectionId) + throw new TypedControlError("operation_prohibited", "An authenticated SDK connection is required."); + return connectionId; +} + +/** Session-scoped, connection-owned image leases; no client-supplied path or identity is trusted. */ +export class PromptImageUploadStore { + readonly #uploads = new Map(); + readonly #isConnectionOpen: (connectionId: string) => boolean; + #closed = false; + #uploadBytes = 0; + #acceptedBytes = 0; + + /** The host supplies live socket membership; standalone callers must choose an explicit policy. */ + constructor(isConnectionOpen: (connectionId: string) => boolean) { + this.#isConnectionOpen = isConnectionOpen; + } + + begin(connectionId: string | undefined, value: unknown): { id: string; nextSequence: 0 } { + this.#assertOpen(); + const owner = ownerId(connectionId); + this.#assertConnected(owner); + const input = object(value, ["mimeType", "byteLength", "sha256", "batchId"]); + if ( + typeof input.mimeType !== "string" || + !["image/png", "image/jpeg", "image/webp", "image/gif"].includes(input.mimeType) + ) + invalid("Unsupported image MIME type."); + if ( + !Number.isSafeInteger(input.byteLength) || + (input.byteLength as number) <= 0 || + (input.byteLength as number) > MAX_IMAGE_INPUT_BYTES + ) + invalid("Image byteLength exceeds the 20 MiB limit."); + if (typeof input.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(input.sha256)) + invalid("Invalid image SHA-256 digest."); + if ( + Object.hasOwn(input, "batchId") && + (typeof input.batchId !== "string" || input.batchId.length === 0 || input.batchId.length > MAX_BATCH_ID_LENGTH) + ) + invalid("Image batchId must be a nonempty string of at most 128 characters."); + const now = Date.now(); + for (const [uploadId, upload] of this.#uploads) this.#expire(uploadId, upload, now); + if (this.#uploads.size >= MAX_UPLOADS) busy("Too many concurrent image uploads."); + const id = crypto.randomUUID(); + const timer = setTimeout(() => this.#remove(id), LEASE_MS); + timer.unref?.(); + const upload: Upload = { + owner, + ...(typeof input.batchId === "string" ? { batchId: input.batchId } : {}), + mimeType: input.mimeType, + byteLength: input.byteLength as number, + sha256: input.sha256, + chunks: [], + reservedBytes: 0, + length: 0, + sequence: 0, + finishing: false, + finished: false, + expiresAt: now + LEASE_MS, + timer, + }; + this.#uploads.set(id, upload); + this.#renewBatch(owner, id, upload); + return { id, nextSequence: 0 }; + } + + append( + connectionId: string | undefined, + value: unknown, + ): { id: string; nextSequence: number; receivedBytes: number } { + const input = object(value, ["id", "sequence", "data"]); + const upload = this.#owned(connectionId, input.id); + if (upload.finishing || upload.finished) invalid("Image upload is already finishing or finished."); + if (!Number.isSafeInteger(input.sequence) || input.sequence !== upload.sequence) + invalid("Image chunk sequence is out of order."); + if ( + typeof input.data !== "string" || + input.data.length === 0 || + input.data.length > (MAX_CHUNK_BYTES * 4) / 3 || + !CANONICAL_BASE64.test(input.data) + ) + invalid("Image chunk must be canonical base64 within the frame limit."); + const bytes = Buffer.from(input.data, "base64"); + if (bytes.length === 0 || bytes.length > MAX_CHUNK_BYTES || bytes.toString("base64") !== input.data) + invalid("Image chunk must be canonical base64 within the frame limit."); + if (upload.length + bytes.length > upload.byteLength) invalid("Image upload exceeds its declared size."); + const nextLength = upload.length + bytes.length; + const allocationLimit = Math.max(upload.byteLength, MIN_RETAINED_BUFFER_BYTES); + const requiredBytes = Math.min(Math.ceil(nextLength / MAX_CHUNK_BYTES) * MAX_CHUNK_BYTES, allocationLimit); + const addedBytes = requiredBytes - upload.reservedBytes; + if ( + this.#uploadBytes + addedBytes > MAX_PASTED_IMAGE_SOURCE_BYTES || + processBytes + transientBytes + addedBytes > MAX_PROCESS_BYTES + ) + busy("Image staging capacity exceeded."); + + // Reserve coalesced fixed-size slabs before allocating or retaining them. Tiny declared + // uploads reserve at least 4 KiB, avoiding one retained Buffer per admitted byte. + this.#uploadBytes += addedBytes; + processBytes += addedBytes; + const originalChunkCount = upload.chunks.length; + try { + const newChunks: Buffer[] = []; + for (let allocated = upload.reservedBytes; allocated < requiredBytes; ) { + const chunkBytes = Math.min(MAX_CHUNK_BYTES, requiredBytes - allocated); + newChunks.push(Buffer.allocUnsafeSlow(chunkBytes)); + allocated += chunkBytes; + } + + let sourceOffset = 0; + let targetOffset = upload.length; + while (sourceOffset < bytes.length) { + const chunkIndex = Math.floor(targetOffset / MAX_CHUNK_BYTES); + const chunk = + chunkIndex < upload.chunks.length + ? upload.chunks[chunkIndex]! + : newChunks[chunkIndex - upload.chunks.length]!; + const chunkOffset = targetOffset % MAX_CHUNK_BYTES; + const copied = Math.min(bytes.length - sourceOffset, chunk.length - chunkOffset); + bytes.copy(chunk, chunkOffset, sourceOffset, sourceOffset + copied); + sourceOffset += copied; + targetOffset += copied; + } + + if (newChunks.length > 0) upload.chunks.push(...newChunks); + upload.reservedBytes = requiredBytes; + upload.length = nextLength; + upload.sequence++; + } catch (error) { + upload.chunks.length = originalChunkCount; + this.#uploadBytes -= addedBytes; + processBytes -= addedBytes; + throw error; + } + this.#renewBatch(upload.owner, input.id as string, upload); + return { id: input.id as string, nextSequence: upload.sequence, receivedBytes: upload.length }; + } + + async finish( + connectionId: string | undefined, + value: unknown, + ): Promise<{ id: string; byteLength: number; sha256: string; mimeType: string }> { + const input = object(value, ["id"]); + const upload = this.#owned(connectionId, input.id); + if (upload.finishing || upload.finished) invalid("Image upload is already finishing or finished."); + if (upload.length !== upload.byteLength) invalid("Image upload is incomplete."); + // Claim the lease, decoder slot and copy capacity before the first allocation or await. + if ( + concurrentFinishes >= MAX_CONCURRENT_FINISHES || + processBytes + transientBytes + upload.length > MAX_PROCESS_BYTES + ) + busy("Image finalization capacity exceeded."); + upload.finishing = true; + concurrentFinishes++; + transientBytes += upload.length; + let transientReservation = upload.length; + try { + const bytes = Buffer.concat(upload.chunks, upload.length); + if (crypto.createHash("sha256").update(bytes).digest("hex") !== upload.sha256) + invalid("Image SHA-256 digest mismatch."); + const metadata = parseImageMetadata(bytes); + if ( + !metadata || + metadata.mimeType !== upload.mimeType || + !metadata.width || + !metadata.height || + metadata.width > MAX_PASTED_IMAGE_DIMENSION || + metadata.height > MAX_PASTED_IMAGE_DIMENSION || + metadata.width * metadata.height > MAX_PASTED_IMAGE_PIXELS + ) + invalid("Image MIME type, structure or dimensions are invalid."); + const decodedBytes = metadata.width * metadata.height * 4; + if (processBytes + transientBytes + decodedBytes > MAX_PROCESS_BYTES) + busy("Image decoding capacity exceeded."); + transientBytes += decodedBytes; + transientReservation += decodedBytes; + try { + const decoded = await new Bun.Image(bytes).metadata(); + if (decoded.width !== metadata.width || decoded.height !== metadata.height) + invalid("Image dimensions do not match decoded data."); + await new Bun.Image(bytes).resize(1, 1).png().bytes(); + } catch { + invalid("Image cannot be decoded."); + } + // The transport can disconnect while decoding. A removed lease cannot be resurrected. + if (this.#uploads.get(input.id as string) !== upload || this.#expire(input.id as string, upload)) + throw new TypedControlError("resource_gone", "Image upload expired."); + const releasedBytes = upload.reservedBytes - upload.length; + this.#uploadBytes -= releasedBytes; + processBytes -= releasedBytes; + upload.chunks = [bytes]; + upload.reservedBytes = upload.length; + upload.finished = true; + this.#renewBatch(upload.owner, input.id as string, upload); + return { id: input.id as string, byteLength: upload.length, sha256: upload.sha256, mimeType: upload.mimeType }; + } finally { + // A discarded lease still owns its source chunks until this async decode settles. + if (this.#uploads.get(input.id as string) !== upload) { + this.#uploadBytes -= upload.reservedBytes; + processBytes -= upload.reservedBytes; + } + transientBytes -= transientReservation; + concurrentFinishes--; + upload.finishing = false; + } + } + + discard(connectionId: string | undefined, value: unknown): { discarded: true } { + const input = object(value, ["id"]); + this.#owned(connectionId, input.id); + this.#remove(input.id as string); + return { discarded: true }; + } + + /** Synchronous reserve–validate–copy–consume transaction, called before prompt admission. */ + redeem(connectionId: string | undefined, value: unknown): { images: ImageContent[]; release: () => void } { + this.#assertOpen(); + const owner = ownerId(connectionId); + this.#assertConnected(owner); + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_IMAGES) + invalid("stagedImages must contain 1–16 image IDs."); + const seen = new Set(); + const entries: Array<[string, Upload]> = []; + let bytes = 0; + for (const descriptor of value) { + const input = object(descriptor, ["id"]); + const id = input.id; + if (typeof id !== "string" || !id || seen.has(id)) + invalid("stagedImages contains an invalid or duplicate ID."); + seen.add(id); + const entry = this.#uploads.get(id); + if (!entry || entry.owner !== owner || this.#expire(id, entry)) + throw new TypedControlError("resource_gone", "Image upload is unavailable."); + if (!entry.finished) invalid("Image upload is not finished."); + bytes += entry.length; + entries.push([id, entry]); + } + const encodedBytes = entries.reduce((total, [, entry]) => total + Math.ceil(entry.length / 3) * 4, 0); + if ( + bytes > MAX_PASTED_IMAGE_SOURCE_BYTES || + this.#acceptedBytes + bytes > MAX_ACCEPTED_BYTES || + processBytes + transientBytes + bytes + encodedBytes > MAX_PROCESS_BYTES + ) + busy("Accepted image capacity exceeded."); + this.#acceptedBytes += bytes; + processBytes += bytes + encodedBytes; + let released = false; + const release = () => { + if (released) return; + released = true; + this.#acceptedBytes -= bytes; + processBytes -= bytes + encodedBytes; + }; + try { + const images: ImageContent[] = entries.map(([, entry]) => ({ + type: "image", + data: entry.chunks[0]!.toString("base64"), + mimeType: entry.mimeType, + })); + for (const [id] of entries) this.#remove(id); + return { images, release }; + } catch (error) { + release(); + throw error; + } + } + + disconnect(connectionId: string): void { + for (const [id, upload] of this.#uploads) if (upload.owner === connectionId) this.#remove(id); + } + close(): void { + this.#closed = true; + for (const id of this.#uploads.keys()) this.#remove(id); + } + #owned(connectionId: string | undefined, value: unknown): Upload { + this.#assertOpen(); + const owner = ownerId(connectionId); + this.#assertConnected(owner); + if (typeof value !== "string" || !value) invalid("Image upload ID is required."); + const upload = this.#uploads.get(value); + if (!upload || upload.owner !== owner || this.#expire(value, upload)) + throw new TypedControlError("resource_gone", "Image upload is unavailable."); + return upload; + } + #assertOpen(): void { + if (this.#closed) throw new TypedControlError("resource_gone", "Image upload session is closed."); + } + #assertConnected(owner: string): void { + if (!this.#isConnectionOpen(owner)) + throw new TypedControlError("resource_gone", "Image upload connection is closed."); + } + #expire(id: string, upload: Upload, now = Date.now()): boolean { + if (now < upload.expiresAt) return false; + this.#remove(id); + return true; + } + #renewBatch(owner: string, id: string, current: Upload): void { + const now = Date.now(); + if (this.#uploads.get(id) !== current || this.#expire(id, current, now)) + throw new TypedControlError("resource_gone", "Image upload expired."); + if (current.batchId === undefined) { + this.#resetLease(id, current, now); + return; + } + for (const [uploadId, upload] of this.#uploads) + if (upload.owner === owner && upload.batchId === current.batchId && !this.#expire(uploadId, upload, now)) + this.#resetLease(uploadId, upload, now); + } + #resetLease(id: string, upload: Upload, now: number): void { + clearTimeout(upload.timer); + upload.expiresAt = now + LEASE_MS; + upload.timer = setTimeout(() => this.#remove(id), LEASE_MS); + upload.timer.unref?.(); + } + #remove(id: string): void { + const upload = this.#uploads.get(id); + if (!upload) return; + this.#uploads.delete(id); + clearTimeout(upload.timer); + if (!upload.finishing) { + this.#uploadBytes -= upload.reservedBytes; + processBytes -= upload.reservedBytes; + } + } +} diff --git a/packages/coding-agent/src/sdk/host/session-runtime.test.ts b/packages/coding-agent/src/sdk/host/session-runtime.test.ts index 13d981d6c68..433cb2d4f3c 100644 --- a/packages/coding-agent/src/sdk/host/session-runtime.test.ts +++ b/packages/coding-agent/src/sdk/host/session-runtime.test.ts @@ -15,6 +15,8 @@ import { AsyncJobManager } from "../../async"; import { ModelRegistry } from "../../config/model-registry"; import { Settings } from "../../config/settings"; import type { ExtensionAPI, ExtensionContext, ExtensionTranscriptEntry } from "../../extensibility/extensions"; +import { ExtensionRuntime, loadExtensionFromFactory } from "../../extensibility/extensions/loader"; +import { ExtensionRunner } from "../../extensibility/extensions/runner"; import { AgentSession } from "../../session/agent-session"; import { AuthStorage } from "../../session/auth-storage"; import { SessionManager } from "../../session/session-manager"; @@ -24,6 +26,7 @@ import { type TurnRegistrationKey, unregisterOwnedRegistration, } from "../../session/terminal-abort"; +import { EventBus } from "../../utils/event-bus"; import { Broker } from "../broker/broker"; import { createKindAwareReconciliation } from "../bus/kind-aware-reconciliation"; import { createPromptReconciliation } from "../bus/prompt-reconciliation"; @@ -35,6 +38,7 @@ import { import { PromptDeadlineManager } from "../prompt-deadline-manager"; import { BROKER_RUNTIME_ABORT_CAPABILITY_FIELD, setBrokerRuntimeAbortCapabilityForTest } from "./control/runtime-gate"; import { SESSION_HOST_OBSERVER_CAPABILITY, TURN_STREAM_CAPABILITY } from "./host"; +import { PromptImageUploadStore } from "./prompt-image-upload"; import { CursorRegistry, QueryHandlers, type QueryResponse, RevisionStore } from "./query"; import { createInvocationReconciliation, @@ -4249,6 +4253,7 @@ interface PreflightHooks { onPreflightAcceptCommit?: () => void | Promise; expectedSdkRunToken?: string; preflightSignal?: AbortSignal; + queuedAtDispatch?: boolean; onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void; } @@ -4261,6 +4266,8 @@ interface ResponseFrame { status?: string; commandId?: string; turnId?: string; + id?: string; + accepted?: boolean; error?: { code: string; message: string }; }; } @@ -4293,6 +4300,7 @@ async function invocationHarness( cwd: string, hooks: { sendUserMessage?: (content: unknown, options?: PreflightHooks & { deliverAs?: string }) => Promise; + preserveSendResult?: boolean; invokeSkill?: (name: string, args?: string, options?: PreflightHooks) => Promise; abort?: () => void; isIdle?: () => boolean; @@ -4333,7 +4341,7 @@ async function invocationHarness( }, sendUserMessage: async (content: unknown, options?: PreflightHooks & { deliverAs?: string }) => { const result = await hooks.sendUserMessage?.(content, options); - return result === undefined ? "completed" : result; + return hooks.preserveSendResult ? result : result === undefined ? "completed" : result; }, } as unknown as ExtensionAPI; const interceptorStore = hooks.persistInterceptor @@ -4366,34 +4374,43 @@ async function invocationHarness( ? { onInvocationCompletionReconciledForTests: hooks.onInvocationCompletionReconciled } : {}), ...(hooks.settings ? { settings: hooks.settings } : {}), - createTransport: async ({ sessionId: id, stateRoot, token }) => ({ - sessionId: id, - stateRoot, - token, - onFrame(handler) { - deliver = handler; - deliveries.set(id, handler); - return () => { - if (deliver === handler) deliver = undefined; - if (deliveries.get(id) === handler) deliveries.delete(id); - }; - }, - sendFrame(_connectionId, frame) { - const response = frame as ResponseFrame; - const frames = sentFrames.get(id) ?? []; - frames.push(frame); - sentFrames.set(id, frames); - if (typeof response.id === "string") waiters.get(response.id)?.(response); - }, - broadcastFrame(frame) { - // Interception precedes recording: a frame whose publication throws never - // reached the wire, so it must not appear in the observed broadcasts. - hooks.broadcastInterceptor?.(frame); - broadcasts.push(frame); - }, - start: async () => ({ url: "ws://127.0.0.1:1" }), - stop: async () => {}, - }), + createTransport: async ({ sessionId: id, stateRoot, token }) => { + let open = false; + return { + sessionId: id, + stateRoot, + token, + isConnectionOpen: () => open, + onFrame(handler) { + deliver = handler; + deliveries.set(id, handler); + return () => { + if (deliver === handler) deliver = undefined; + if (deliveries.get(id) === handler) deliveries.delete(id); + }; + }, + sendFrame(_connectionId, frame) { + const response = frame as ResponseFrame; + const frames = sentFrames.get(id) ?? []; + frames.push(frame); + sentFrames.set(id, frames); + if (typeof response.id === "string") waiters.get(response.id)?.(response); + }, + broadcastFrame(frame) { + // Interception precedes recording: a frame whose publication throws never + // reached the wire, so it must not appear in the observed broadcasts. + hooks.broadcastInterceptor?.(frame); + broadcasts.push(frame); + }, + start: async () => { + open = true; + return { url: "ws://127.0.0.1:1" }; + }, + stop: async () => { + open = false; + }, + }; + }, }); const ctx = { cwd, @@ -4479,6 +4496,516 @@ async function invocationHarness( }; } +test("SDK-only host retains accepted staged bytes until terminal and releases rejected images", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-only-staged-image-")); + const originalRedeem = PromptImageUploadStore.prototype.redeem; + const releases: string[] = []; + const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function ( + this: PromptImageUploadStore, + owner, + ids, + ) { + const reservation = originalRedeem.call(this, owner, ids); + return { + images: reservation.images, + release: () => { + releases.push("released"); + reservation.release(); + }, + }; + }); + let harness: InvocationHarness | undefined; + try { + const sent: unknown[] = []; + const queuedPromotions: Array> = []; + const activeHarness = await invocationHarness("sdk-only-image-test", cwd, { + sendUserMessage: async (content, options) => { + sent.push(content); + if (options?.onQueuedPromoted) queuedPromotions.push(options.onQueuedPromoted); + await options?.onPreflightAcceptCommit?.(); + await Promise.withResolvers().promise; + }, + }); + harness = activeHarness; + const bytes = Buffer.from( + await Bun.file(new URL("../../../test/fixtures/sdk-inline-image-large.png", import.meta.url)).arrayBuffer(), + ); + expect(bytes.length).toBeGreaterThan(256 * 1024); + const digest = createHash("sha256").update(bytes).digest("hex"); + const stage = async (): Promise => { + const begun = await activeHarness.control("turn.image.begin", { + mimeType: "image/png", + byteLength: bytes.length, + sha256: digest, + }); + const id = begun.result?.id; + expect(begun.ok).toBe(true); + if (!id) throw new Error("Host did not return a staged image ID."); + expect(id).toMatch(/^[0-9a-f]{8}-/); + let sequence = 0; + for (let offset = 0; offset < bytes.length; offset += 96 * 1024) { + const data = bytes.subarray(offset, offset + 96 * 1024).toString("base64"); + expect(Buffer.byteLength(JSON.stringify({ id, sequence, data }))).toBeLessThan(256 * 1024); + expect((await activeHarness.control("turn.image.append", { id, sequence, data })).ok).toBe(true); + sequence++; + } + expect((await activeHarness.control("turn.image.finish", { id })).ok).toBe(true); + return id; + }; + const id = await stage(); + const accepted = await harness.control("turn.prompt", { text: "Read image", stagedImages: [{ id }] }); + expect(accepted).toMatchObject({ ok: true, result: { accepted: true } }); + expect(releases).toEqual([]); + expect((sent[0] as Array<{ type: string; data?: string }>)[1]?.data).toBe(bytes.toString("base64")); + const rejectedId = await stage(); + expect( + await harness.control("turn.prompt", { + text: "Reject this", + stagedImages: [{ id: rejectedId }], + clientRef: " ", + }), + ).toMatchObject({ ok: false, error: { code: "invalid_input" } }); + expect(releases).toHaveLength(1); + expect(sent).toHaveLength(1); + await harness.emit("agent_start", { type: "agent_start" }); + await harness.emit("agent_end", { + messages: [{ role: "assistant", stopReason: "stop", content: "Completed." }], + }); + expect(releases).toHaveLength(2); + await harness.emit("agent_end", { + messages: [{ role: "assistant", stopReason: "stop", content: "Completed." }], + }); + expect(releases).toHaveLength(2); + const racedId = await stage(); + const removed = await harness.control("turn.prompt", { + text: "Diverted after idle snapshot", + stagedImages: [{ id: racedId }], + }); + expect(removed).toMatchObject({ ok: true, result: { accepted: true } }); + expect(releases).toHaveLength(2); + queuedPromotions[1]?.({ startsOwnRun: false, removed: true }); + expect( + await settledStatus(harness, "turn.result", { + kind: "prompt", + commandId: removed.result?.commandId, + turnId: removed.result?.turnId, + }), + ).toMatchObject({ status: "failed", error: { code: "cancelled" } }); + expect(releases).toHaveLength(3); + await harness.stop(); + harness = undefined; + expect(releases).toHaveLength(3); + } finally { + await harness?.stop(); + redeemSpy.mockRestore(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test.each(["natural", "removed"] as const)("SDK-only staged diversion has a durable terminal (%s)", async mode => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-only-diverted-image-")); + let harness: InvocationHarness | undefined; + let promotion: PreflightHooks["onQueuedPromoted"]; + try { + harness = await invocationHarness(`sdk-only-diverted-${mode}`, cwd, { + sendUserMessage: async (content, options) => { + await options?.onPreflightAcceptCommit?.(); + if (Array.isArray(content)) { + options?.onDispatchDisposition?.({ startsOwnRun: false }); + promotion = options?.onQueuedPromoted; + return; + } + await neverSettlingPromise(); + }, + }); + const original = await harness.control("turn.prompt", { text: "original" }); + expect(original.ok).toBe(true); + await harness.emit("agent_start"); + const bytes = Buffer.from( + await Bun.file(new URL("../../../test/fixtures/sdk-inline-image-large.png", import.meta.url)).arrayBuffer(), + ); + const begun = await harness.control("turn.image.begin", { + mimeType: "image/png", + byteLength: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + expect(begun.ok).toBe(true); + const id = begun.result?.id; + let sequence = 0; + for (let offset = 0; offset < bytes.length; offset += 96 * 1024) { + expect( + await harness.control("turn.image.append", { + id, + sequence: sequence++, + data: bytes.subarray(offset, offset + 96 * 1024).toString("base64"), + }), + ).toMatchObject({ ok: true }); + } + expect(await harness.control("turn.image.finish", { id })).toMatchObject({ ok: true }); + const admitted = await harness.control("turn.prompt", { text: "joined image", stagedImages: [{ id }] }); + expect(admitted).toMatchObject({ ok: true, result: { accepted: true } }); + const correlation = { commandId: admitted.result?.commandId, turnId: admitted.result?.turnId }; + expect((await harness.query("turn.result", { kind: "prompt", ...correlation })).result?.status).toBe("accepted"); + expect(promotion).toBeDefined(); + promotion?.({ startsOwnRun: false, ...(mode === "removed" ? { removed: true } : {}) }); + if (mode === "natural") + await harness.emit("agent_end", { + messages: [{ role: "assistant", stopReason: "stop", content: "image done" }], + }); + expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation })).toMatchObject( + mode === "natural" ? { status: "terminal_ok" } : { status: "failed", error: { code: "cancelled" } }, + ); + await harness.emit("agent_start"); + await harness.emit("agent_end", { messages: [{ role: "assistant", stopReason: "stop", content: "unrelated" }] }); + expect( + harness.broadcasts.filter( + frame => + frame.type === "event" && + frame.kind === "agent_end" && + (frame.payload as { commandId?: string; turnId?: string } | undefined)?.commandId === + correlation.commandId && + (frame.payload as { commandId?: string; turnId?: string } | undefined)?.turnId === correlation.turnId, + ), + ).toHaveLength(1); + } finally { + await harness?.stop(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test.each([ + "ordinary", + "terminal", +] as const)("SDK-only %s abort cancels only its accepted image queue owner behind a real active AgentSession run", async mode => { + const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-queued-image-${mode}-`)); + const rootGate = Promise.withResolvers(); + const rootStarted = Promise.withResolvers(); + const failedRemovalWrite = Promise.withResolvers(); + const modelCalls: number[] = []; + let rootAbortCalls = 0; + let imagePreflightSignal: AbortSignal | undefined; + let harness: InvocationHarness | undefined; + let session: AgentSession | undefined; + let authStorage: AuthStorage | undefined; + try { + authStorage = await AuthStorage.create(path.join(cwd, "testauth.db")); + const mock = createMockModel({ + responses: [ + async () => { + modelCalls.push(Date.now()); + rootStarted.resolve(); + await rootGate.promise; + return { content: ["B root completed"] }; + }, + () => { + modelCalls.push(Date.now()); + return { content: ["unrelated queued steer completed"] }; + }, + ], + }); + authStorage.setRuntimeApiKey(mock.model.provider, "test-key"); + const modelRegistry = new ModelRegistry(authStorage); + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: mock.stream, + }); + const settings = Settings.isolated({ "compaction.enabled": false }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(cwd), + settings, + modelRegistry, + }); + const hooks: Parameters[2] = { + isIdle: () => !session!.isStreaming, + abort: () => { + rootAbortCalls += 1; + }, + sendUserMessage: (content, sendOptions) => { + if ( + Array.isArray(content) && + content.some(block => typeof block === "object" && block !== null && block.type === "image") + ) + imagePreflightSignal = sendOptions?.preflightSignal; + return session!.sendUserMessage(content as never, sendOptions as never); + }, + ...(mode === "ordinary" + ? { + persistInterceptor: transition => { + if (transition.type === "agent_failed") failedRemovalWrite.resolve(); + }, + agentFailedWriteFailures: 1, + onDurableAttempt: attempt => { + if (attempt.type === "agent_failed" && attempt.outcome === "rejected") + failedRemovalWrite.resolve(); + }, + } + : { + terminalAbortSeams: { + getTerminalTurnEpoch: () => (session?.isStreaming ? 17 : undefined), + getActivePromptHandle: () => (session?.isStreaming ? "B-root-handle" : undefined), + getActivePromptOwnerConnectionId: () => (session?.isStreaming ? "B" : undefined), + cancelPendingPreflightForTerminalAbort: () => {}, + abortPromptAndWaitWithTerminal: async () => { + rootAbortCalls += 1; + return { status: "settled", terminalScope: {} }; + }, + }, + }), + }; + harness = await invocationHarness(`queued-image-${mode}`, cwd, hooks); + session.subscribe(async event => { + await harness?.emit(event.type, event); + }); + + const rootAccepted = await harness.controlAs("B", "turn.prompt", { text: "B owns the active root" }); + expect(rootAccepted).toMatchObject({ ok: true, result: { accepted: true } }); + await rootStarted.promise; + const keepQueued = await harness.controlAs("C", "turn.prompt", { text: "keep unrelated queue item" }); + expect(keepQueued).toMatchObject({ ok: true, result: { accepted: true } }); + + const imageBytes = Buffer.from( + await Bun.file(new URL("../../../test/fixtures/sdk-inline-image-large.png", import.meta.url)).arrayBuffer(), + ); + const begun = await harness.controlAs("A", "turn.image.begin", { + mimeType: "image/png", + byteLength: imageBytes.length, + sha256: createHash("sha256").update(imageBytes).digest("hex"), + }); + const imageId = begun.result?.id; + expect(begun.ok).toBe(true); + if (typeof imageId !== "string") throw new Error("SDK host did not return a staged image ID."); + let sequence = 0; + for (let offset = 0; offset < imageBytes.length; offset += 96 * 1024) { + const data = imageBytes.subarray(offset, offset + 96 * 1024).toString("base64"); + expect( + await harness.controlAs("A", "turn.image.append", { id: imageId, sequence: sequence++, data }), + ).toMatchObject({ ok: true }); + } + expect(await harness.controlAs("A", "turn.image.finish", { id: imageId })).toMatchObject({ ok: true }); + const acceptedImage = await harness.controlAs("A", "turn.prompt", { + text: "cancel exact queued image", + stagedImages: [{ id: imageId }], + }); + expect(acceptedImage).toMatchObject({ ok: true, result: { accepted: true } }); + const imageCorrelation = { + commandId: acceptedImage.result?.commandId, + turnId: acceptedImage.result?.turnId, + }; + expect(imagePreflightSignal).toBeInstanceOf(AbortSignal); + expect(imagePreflightSignal?.aborted).toBe(false); + expect(session.getQueuedMessages().steering).toEqual(["keep unrelated queue item", "cancel exact queued image"]); + + let abortCompleted = false; + const abortRequest = + mode === "ordinary" + ? harness.controlAs("A", "turn.abort", { mode: "turn" }) + : harness.controlAs("A", "turn.abort", { mode: "terminal" }, `queued-image-${mode}-key`); + const observedAbort = abortRequest.then(response => { + abortCompleted = true; + return response; + }); + if (mode === "ordinary") { + await failedRemovalWrite.promise; + await Bun.sleep(0); + expect(abortCompleted).toBe(false); + } + const abortResponse = await observedAbort; + if (mode === "ordinary") expect(abortResponse).toMatchObject({ ok: true, result: { aborted: true } }); + else + expect(abortResponse).toMatchObject({ + ok: true, + result: { turn: "no_active_turn", terminal: "terminal_no_effect" }, + }); + expect(imagePreflightSignal?.aborted).toBe(true); + expect(rootAbortCalls).toBe(0); + expect(session.isStreaming).toBe(true); + expect(session.getQueuedMessages().steering).toEqual(["keep unrelated queue item"]); + expect(await settledStatus(harness, "turn.result", { kind: "prompt", ...imageCorrelation })).toMatchObject({ + status: "failed", + error: { code: "cancelled" }, + }); + if (mode === "ordinary") { + const repeatedAbort = await harness.controlAs("A", "turn.abort", { mode: "turn" }); + expect(repeatedAbort).toMatchObject({ + ok: true, + result: { aborted: false, turn: "no_active_turn" }, + }); + expect(rootAbortCalls).toBe(0); + expect(session.isStreaming).toBe(true); + expect(session.getQueuedMessages().steering).toEqual(["keep unrelated queue item"]); + } + + rootGate.resolve(); + await session.waitForIdle(); + const userMessages = session.agent.state.messages; + const hasUserText = (text: string): boolean => + userMessages.some(message => { + if (message.role !== "user") return false; + return typeof message.content === "string" + ? message.content === text + : message.content.some(block => block.type === "text" && block.text === text); + }); + expect(hasUserText("keep unrelated queue item")).toBe(true); + expect(hasUserText("cancel exact queued image")).toBe(false); + expect( + userMessages.some( + message => + message.role === "user" && + Array.isArray(message.content) && + message.content.some(block => block.type === "image" && block.data === imageBytes.toString("base64")), + ), + ).toBe(false); + expect(modelCalls).toHaveLength(2); + const correlatedTerminals = harness.broadcasts.filter( + frame => + frame.type === "event" && + frame.kind === "agent_end" && + (frame.payload as { commandId?: string; turnId?: string } | undefined)?.commandId === + imageCorrelation.commandId && + (frame.payload as { commandId?: string; turnId?: string } | undefined)?.turnId === imageCorrelation.turnId, + ); + expect(correlatedTerminals).toHaveLength(1); + } finally { + rootGate.resolve(); + await session?.waitForIdle().catch(() => undefined); + await harness?.stop(); + await session?.dispose(); + authStorage?.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("SDK-only ordinary abort selects its owned root ahead of same-connection queued input", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-only-owned-root-selection-")); + const rootStarted = Promise.withResolvers(); + const releaseRoot = Promise.withResolvers(); + let harness: InvocationHarness | undefined; + let session: AgentSession | undefined; + let authStorage: AuthStorage | undefined; + let rootAborts = 0; + let queuedSignal: AbortSignal | undefined; + const modelCalls: string[] = []; + const observedSessionEnds: Array> = []; + try { + authStorage = await AuthStorage.create(path.join(cwd, "testauth.db")); + const mock = createMockModel({ + responses: [ + async () => { + modelCalls.push("root"); + rootStarted.resolve(); + await releaseRoot.promise; + return { content: ["root provider released after abort"] }; + }, + () => { + modelCalls.push("queued"); + return { content: ["queued successor completed"] }; + }, + ], + }); + authStorage.setRuntimeApiKey(mock.model.provider, "test-key"); + const manager = SessionManager.inMemory(cwd); + const settings = Settings.isolated({ "compaction.enabled": false }); + const modelRegistry = new ModelRegistry(authStorage); + const extensionRuntime = new ExtensionRuntime(); + const sdkLifecycle = await loadExtensionFromFactory( + api => { + api.on("agent_start", event => harness?.emit(event.type, event)); + api.on("agent_failed", event => harness?.emit(event.type, event)); + api.on("agent_end", event => { + observedSessionEnds.push({ sdkRunToken: event.sdkRunToken }); + return harness?.emit(event.type, event); + }); + }, + cwd, + new EventBus(), + extensionRuntime, + "sdk-root-priority-lifecycle", + ); + const runner = new ExtensionRunner( + [sdkLifecycle], + extensionRuntime, + cwd, + manager, + modelRegistry, + undefined, + settings, + ); + session = new AgentSession({ + agent: new Agent({ + getApiKey: () => "test-key", + initialState: { model: mock.model, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: mock.stream, + }), + sessionManager: manager, + settings, + modelRegistry, + extensionRunner: runner, + }); + harness = await invocationHarness("sdk-only-owned-root-selection", cwd, { + preserveSendResult: true, + isIdle: () => !session!.isStreaming, + abort: async () => { + rootAborts++; + const aborting = session!.abort({ cause: "user_interrupt" }); + releaseRoot.resolve(); + await aborting; + }, + sendUserMessage: (content, options) => { + if (typeof content !== "string") throw new Error("Expected text-only real root admission."); + if (content === "queued for later") queuedSignal = options?.preflightSignal; + const { deliverAs, ...admissionOptions } = options ?? {}; + if (deliverAs !== undefined && deliverAs !== "steer" && deliverAs !== "followUp") + throw new Error("Unsupported real admission delivery mode."); + return session!.sendUserMessage(content, { + ...admissionOptions, + ...(deliverAs === undefined ? {} : { deliverAs }), + }); + }, + }); + const acceptedRoot = await harness.control("turn.prompt", { text: "root", clientRef: "owned-root" }); + expect(acceptedRoot).toMatchObject({ ok: true, result: { accepted: true } }); + expect(typeof acceptedRoot.result?.commandId).toBe("string"); + expect(typeof acceptedRoot.result?.turnId).toBe("string"); + await rootStarted.promise; + const acceptedSuccessor = await harness.control("turn.prompt", { + text: "queued for later", + clientRef: "owned-successor", + }); + expect(acceptedSuccessor).toMatchObject({ ok: true, result: { accepted: true } }); + expect(typeof acceptedSuccessor.result?.commandId).toBe("string"); + expect(typeof acceptedSuccessor.result?.turnId).toBe("string"); + expect(modelCalls).toEqual(["root"]); + expect(session.getQueuedMessages().steering).toEqual(["queued for later"]); + expect(queuedSignal?.aborted).toBe(false); + expect(await harness.control("turn.abort", {})).toMatchObject({ ok: true, result: { aborted: true } }); + expect(rootAborts).toBe(1); + expect(queuedSignal?.aborted).toBe(false); + await session.waitForIdle(); + expect(modelCalls).toEqual(["root", "queued"]); + expect(session.getQueuedMessages().steering).toEqual([]); + expect(await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "owned-root" })).toMatchObject({ + status: "failed", + error: { code: "prompt_failed", message: "Agent run failed after execution started." }, + }); + expect( + await settledStatus(harness, "turn.result", { kind: "prompt", clientRef: "owned-successor" }), + ).toMatchObject({ status: "terminal_ok" }); + expect(observedSessionEnds).toEqual([ + { sdkRunToken: `${acceptedRoot.result?.commandId}:${acceptedRoot.result?.turnId}` }, + { sdkRunToken: `${acceptedSuccessor.result?.commandId}:${acceptedSuccessor.result?.turnId}` }, + ]); + } finally { + releaseRoot.resolve(); + await session?.waitForIdle().catch(() => undefined); + await harness?.stop(); + await session?.dispose(); + authStorage?.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + test.each(["natural", "removed"] as const)("SDK-only text follow-up has a durable terminal (%s)", async mode => { const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-sdk-only-text-followup-${mode}-`)); let harness: InvocationHarness | undefined; @@ -4864,7 +5391,7 @@ test.each([ cancelPendingPreflightForTerminalAbort: () => {}, abortPromptAndWaitWithTerminal: async () => { rootAbortCalls += 1; - return { status: "settled", terminalScope: {} }; + throw new Error("Queued cancellation must not invoke the foreign root terminal seam"); }, }, }), @@ -5402,7 +5929,7 @@ describe("post-acceptance invocation terminalization", () => { } }); - test("terminalizes a synchronous throw during a todo-reminder continuation", async () => { + test("terminalizes the recovered producer after a synchronous todo-continuation throw", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-todo-reminder-throw-")); let harness: InvocationHarness | undefined; let session: AgentSession | undefined; @@ -5414,7 +5941,10 @@ describe("post-acceptance invocation terminalization", () => { async (model, context, options) => { providerCalls++; if (providerCalls === 2) throw new Error("todo continuation stream failed synchronously"); - return createMockModel({ responses: [{ content: ["started"] }] }).stream(model, context, options); + if (providerCalls > 3) throw new Error("Unexpected extra provider attempt"); + return createMockModel({ + responses: [{ content: [providerCalls === 3 ? "recovered continuation" : "started"] }], + }).stream(model, context, options); }, { "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1 }, ); @@ -5436,12 +5966,17 @@ describe("post-acceptance invocation terminalization", () => { expect(correlation.commandId).toBeDefined(); expect(correlation.turnId).toBeDefined(); const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation }); - expect(terminal).toMatchObject(correlation); - expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength(2); + expect(terminal).toMatchObject({ + ...correlation, + status: "terminal_ok", + content: { text: "recovered continuation" }, + }); + expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength(3); const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end"); expect(ends).toHaveLength(1); expect(ends[0]).toMatchObject({ payload: correlation }); - expect(providerCalls).toBe(2); + expect(harness.broadcasts.filter(frame => frame.kind === "agent_failed")).toHaveLength(0); + expect(providerCalls).toBe(3); } finally { await session?.dispose(); authStorage?.close(); @@ -5450,6 +5985,175 @@ describe("post-acceptance invocation terminalization", () => { } }); + for (const submissionMode of [ + "lifecycle-only", + "real-settlement", + "real-void", + "cancelled-real-void", + "cancelled-backoff-real-void", + "durable-recovery-real-void", + "durable-cancellation-real-void", + "cleanup-rejection-durable-real-void", + ] as const) { + test(`keeps a todo continuation retry nonterminal until its actual provider completes (${submissionMode})`, async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-todo-held-retry-")); + let harness: InvocationHarness | undefined; + let session: AgentSession | undefined; + let authStorage: AuthStorage | undefined; + let providerCalls = 0; + const thirdEntered = Promise.withResolvers(); + const retryEntered = Promise.withResolvers(); + let backoffAbort: Promise | undefined; + const cancelledInBackoff = submissionMode === "cancelled-backoff-real-void"; + const cancelledDuringProvider = + submissionMode === "cancelled-real-void" || submissionMode === "durable-cancellation-real-void"; + const durableFault = submissionMode.includes("durable"); + const cleanupWarning = + submissionMode === "cleanup-rejection-durable-real-void" ? spyOn(logger, "warn") : undefined; + const submissionSettled = Promise.withResolvers(); + let terminalWriteAttempts = 0; + let bareCompletionCommits = 0; + const durableTerminalCommits: Array<{ commandId?: string; turnId?: string }> = []; + const releaseThird = Promise.withResolvers(); + try { + const real = await createTerminalizationSession( + cwd, + async (model, context, options) => { + providerCalls++; + if (providerCalls === 2) throw new Error("todo continuation stream failed synchronously"); + if (providerCalls > 3) throw new Error("Unexpected extra provider attempt"); + if (providerCalls === 3) thirdEntered.resolve(); + const response = + providerCalls === 3 + ? async () => { + await releaseThird.promise; + return { content: ["recovered continuation"] }; + } + : { content: ["started"] }; + return createMockModel({ responses: [response] }).stream(model, context, options); + }, + { "todo.enabled": true, "todo.reminders": true, "todo.reminders.max": 1 }, + ); + session = real.session; + authStorage = real.authStorage; + session.setTodoPhases([ + { name: "Work", tasks: [{ content: "finish the outstanding work", status: "pending" }] }, + ]); + harness = await invocationHarness("todo-held-retry", cwd, { + preserveSendResult: submissionMode.endsWith("real-void"), + ...(durableFault + ? { + settings: { + get: (key: string) => + key === "sdk.promptDeadlineMs" + ? 500 + : key === "sdk.promptMaxRuntimeMs" + ? 60_000 + : undefined, + } as unknown as Settings, + agentFailedWriteFailures: 0, + persistInterceptor: (transition: { type: string }) => { + if (transition.type === "agent_end" && ++terminalWriteAttempts <= 3) + throw Object.assign(new Error("Controlled terminal write outage"), { code: "io_error" }); + }, + onDurableAttempt: (attempt: { + type: string | undefined; + commandId?: string; + turnId?: string; + outcome: "rejected" | "committed"; + }) => { + if (attempt.type === "agent_end" && attempt.outcome === "committed") + durableTerminalCommits.push({ commandId: attempt.commandId, turnId: attempt.turnId }); + }, + onInvocationCompletionReconciled: () => { + bareCompletionCommits++; + }, + } + : {}), + isIdle: () => !session?.isStreaming, + sendUserMessage: + submissionMode === "lifecycle-only" + ? deferredRealSendUserMessage(real.session) + : async (content, options) => { + await options?.onPreflightAcceptCommit?.(); + const { onPreflightAcceptCommit: _onPreflightAcceptCommit, ...dispatchOptions } = + options ?? {}; + await real.session.sendUserMessage(content as string, dispatchOptions as never); + submissionSettled.resolve(); + if (submissionMode === "cleanup-rejection-durable-real-void") + throw Object.assign(new Error("Controlled post-publication cleanup rejection"), { + code: "cleanup_failed", + }); + }, + }); + session.subscribe(async event => { + if (cancelledInBackoff && event.type === "auto_retry_start") { + backoffAbort = real.session.abort(); + retryEntered.resolve(); + } + await harness?.emit(event.type, event); + }); + const accepted = await harness.control("turn.prompt", { text: "finish the outstanding work" }); + expect(accepted.ok).toBe(true); + const correlation = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId }; + expect(correlation.commandId).toBeDefined(); + expect(correlation.turnId).toBeDefined(); + await (cancelledInBackoff ? retryEntered.promise : thirdEntered.promise); + expect(providerCalls).toBe(cancelledInBackoff ? 2 : 3); + if (!cancelledInBackoff) { + expect(await harness.query("turn.result", { kind: "prompt", ...correlation })).toMatchObject({ + result: { status: "in_flight", receiptState: "absent" }, + }); + expect(harness.broadcasts.filter(frame => frame.kind === "agent_end")).toHaveLength(0); + } + expect(harness.broadcasts.filter(frame => frame.kind === "agent_failed")).toHaveLength(0); + const abort = cancelledInBackoff ? backoffAbort : cancelledDuringProvider ? session.abort() : undefined; + releaseThird.resolve(); + await abort; + await session.waitForIdle(); + if (durableFault) await submissionSettled.promise; + const terminal = await settledStatus(harness, "turn.result", { kind: "prompt", ...correlation }); + expect(terminal).toMatchObject( + cancelledDuringProvider || cancelledInBackoff + ? { ...correlation, status: "terminal_ok", outcome: { kind: "stopped", reason: "cancelled" } } + : { ...correlation, status: "terminal_ok", content: { text: "recovered continuation" } }, + ); + expect(harness.broadcasts.filter(frame => frame.kind === "agent_start")).toHaveLength( + cancelledInBackoff ? 2 : 3, + ); + const ends = harness.broadcasts.filter(frame => frame.kind === "agent_end"); + if (durableFault) { + // Failed lifecycle writes withhold the wire boundary. The existing + // deadline owner repairs the actual durable result for readonly query; + // do not fake a broadcast or infer delivery from that durable commit. + expect(ends).toHaveLength(0); + } else { + expect(ends).toHaveLength(1); + expect(ends[0]).toMatchObject({ payload: correlation }); + } + expect(harness.broadcasts.filter(frame => frame.kind === "agent_failed")).toHaveLength(0); + expect(providerCalls).toBe(cancelledInBackoff ? 2 : 3); + if (durableFault) { + expect(terminalWriteAttempts).toBe(4); + expect(bareCompletionCommits).toBe(0); + expect(durableTerminalCommits).toEqual([correlation]); + } + if (cleanupWarning) + expect(cleanupWarning).toHaveBeenCalledWith( + "SDK submission cleanup failed after owned terminal capture", + expect.objectContaining({ kind: "prompt", ...correlation }), + ); + } finally { + cleanupWarning?.mockRestore(); + releaseThird.resolve(); + await session?.dispose(); + authStorage?.close(); + await harness?.stop(); + await rm(cwd, { recursive: true, force: true }); + } + }); + } + test("terminalizes a second overflow during overflow maintenance", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-overflow-maintenance-failure-")); let harness: InvocationHarness | undefined; @@ -6889,78 +7593,75 @@ describe("post-acceptance invocation terminalization", () => { category: "agent_runtime", }, }); - const successor = await harness.control("turn.prompt", { text: "successor" }); - expect(successor).toMatchObject({ ok: true, result: { accepted: true } }); + const successor = await harness.control("turn.prompt", { text: "successor" }); + expect(successor).toMatchObject({ ok: true, result: { accepted: true } }); + await harness.emit("agent_start"); + await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] }); + expect( + await settledStatus(harness, "turn.result", { + kind: "prompt", + commandId: successor.result?.commandId, + turnId: successor.result?.turnId, + }), + ).toMatchObject({ status: "terminal_ok" }); + await harness.stop(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + test.each([ + { + name: "reasoning-only", + content: [{ type: "thinking", thinking: "private reasoning" }], + usage: { totalTokens: 0 }, + }, + { + name: "redacted-reasoning-only", + content: [{ type: "redactedThinking", data: "encrypted reasoning" }], + usage: { totalTokens: 0 }, + }, + { + name: "tool-only", + content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }], + usage: { totalTokens: 0 }, + }, + { + name: "positive-token-usage", + content: [{ type: "thinking", thinking: "" }], + usage: { totalTokens: 1 }, + }, + ] as const)("preserves valid textless terminal evidence ($name)", async testCase => { + const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-terminal-valid-${testCase.name}-`)); + try { + const harness = await invocationHarness(`terminal-valid-${testCase.name}`, cwd, { + sendUserMessage: async (_content, options) => { + await options?.onPreflightAcceptCommit?.(); + await Promise.withResolvers().promise; + }, + }); + const accepted = await harness.control("turn.prompt", { text: "hello" }); await harness.emit("agent_start"); - await harness.emit("agent_end", { messages: [{ role: "assistant", content: "completed" }] }); + await harness.emit("agent_end", { + messages: [ + { + role: "assistant", + content: testCase.content, + ...(testCase.usage === undefined ? {} : { usage: testCase.usage }), + }, + ], + }); expect( await settledStatus(harness, "turn.result", { kind: "prompt", - commandId: successor.result?.commandId, - turnId: successor.result?.turnId, + commandId: accepted.result?.commandId, + turnId: accepted.result?.turnId, }), ).toMatchObject({ status: "terminal_ok" }); await harness.stop(); } finally { await rm(cwd, { recursive: true, force: true }); } - }); - test("preserves valid textless terminal evidence", async () => { - const cases = [ - { - name: "reasoning-only", - content: [{ type: "thinking", thinking: "private reasoning" }], - usage: { totalTokens: 0 }, - }, - { - name: "redacted-reasoning-only", - content: [{ type: "redactedThinking", data: "encrypted reasoning" }], - usage: { totalTokens: 0 }, - }, - { - name: "tool-only", - content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }], - usage: { totalTokens: 0 }, - }, - { - name: "positive-token-usage", - content: [{ type: "thinking", thinking: "" }], - usage: { totalTokens: 1 }, - }, - ] as const; - for (const testCase of cases) { - const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-terminal-valid-${testCase.name}-`)); - try { - const harness = await invocationHarness(`terminal-valid-${testCase.name}`, cwd, { - sendUserMessage: async (_content, options) => { - await options?.onPreflightAcceptCommit?.(); - await Promise.withResolvers().promise; - }, - }); - const accepted = await harness.control("turn.prompt", { text: "hello" }); - await harness.emit("agent_start"); - await harness.emit("agent_end", { - messages: [ - { - role: "assistant", - content: testCase.content, - ...(testCase.usage === undefined ? {} : { usage: testCase.usage }), - }, - ], - }); - expect( - await settledStatus(harness, "turn.result", { - kind: "prompt", - commandId: accepted.result?.commandId, - turnId: accepted.result?.turnId, - }), - ).toMatchObject({ status: "terminal_ok" }); - await harness.stop(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - } - }); + }, 30_000); test("preserves earlier complete tool activity before a trailing empty assistant", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminal-earlier-tool-activity-")); try { @@ -7004,106 +7705,103 @@ describe("post-acceptance invocation terminalization", () => { await rm(cwd, { recursive: true, force: true }); } }); - test("fails closed without independent terminal evidence", async () => { - const cases = [ - { name: "usage-omitted", content: [{ type: "thinking", thinking: "" }], omitUsage: true }, - { name: "usage-null", content: [{ type: "thinking", thinking: "" }], usage: null }, - { name: "primitive-usage", content: [{ type: "thinking", thinking: "" }], usage: "bad" }, - { name: "array-usage", content: [{ type: "thinking", thinking: "" }], usage: [] }, - { name: "missing-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { input: 0 } }, - { - name: "undefined-total-tokens", - content: [{ type: "thinking", thinking: "" }], - usage: { totalTokens: undefined }, - }, - { name: "negative-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { totalTokens: -1 } }, - { - name: "nan-total-tokens", - content: [{ type: "thinking", thinking: "" }], - usage: { totalTokens: Number.NaN }, - }, - { - name: "infinite-total-tokens", - content: [{ type: "thinking", thinking: "" }], - usage: { totalTokens: Number.POSITIVE_INFINITY }, - }, - { - name: "incomplete-tool-call", - content: [ - { - type: "toolCall", - id: "call-1", - name: "read", - arguments: {}, - incompleteArguments: true, - incompleteArgumentsReason: "truncated", - }, - ], - usage: { totalTokens: 0 }, - }, - { - name: "malformed-tool-arguments", - content: [{ type: "toolCall", id: "call-1", name: "read", arguments: null }], - usage: { totalTokens: 0 }, - }, - { - name: "orphaned-incomplete-reason", - content: [ + test.each([ + { name: "usage-omitted", content: [{ type: "thinking", thinking: "" }], omitUsage: true }, + { name: "usage-null", content: [{ type: "thinking", thinking: "" }], usage: null }, + { name: "primitive-usage", content: [{ type: "thinking", thinking: "" }], usage: "bad" }, + { name: "array-usage", content: [{ type: "thinking", thinking: "" }], usage: [] }, + { name: "missing-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { input: 0 } }, + { + name: "undefined-total-tokens", + content: [{ type: "thinking", thinking: "" }], + usage: { totalTokens: undefined }, + }, + { name: "negative-total-tokens", content: [{ type: "thinking", thinking: "" }], usage: { totalTokens: -1 } }, + { + name: "nan-total-tokens", + content: [{ type: "thinking", thinking: "" }], + usage: { totalTokens: Number.NaN }, + }, + { + name: "infinite-total-tokens", + content: [{ type: "thinking", thinking: "" }], + usage: { totalTokens: Number.POSITIVE_INFINITY }, + }, + { + name: "incomplete-tool-call", + content: [ + { + type: "toolCall", + id: "call-1", + name: "read", + arguments: {}, + incompleteArguments: true, + incompleteArgumentsReason: "truncated", + }, + ], + usage: { totalTokens: 0 }, + }, + { + name: "malformed-tool-arguments", + content: [{ type: "toolCall", id: "call-1", name: "read", arguments: null }], + usage: { totalTokens: 0 }, + }, + { + name: "orphaned-incomplete-reason", + content: [ + { + type: "toolCall", + id: "call-1", + name: "read", + arguments: {}, + incompleteArgumentsReason: "malformed", + }, + ], + usage: { totalTokens: 0 }, + }, + ] as const)("fails closed without independent terminal evidence ($name)", async testCase => { + const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-terminal-malformed-${testCase.name}-`)); + try { + const harness = await invocationHarness(`terminal-malformed-${testCase.name}`, cwd, { + sendUserMessage: async (_content, options) => { + await options?.onPreflightAcceptCommit?.(); + await Promise.withResolvers().promise; + }, + }); + const accepted = await harness.control("turn.prompt", { text: "hello" }); + await harness.emit("agent_start"); + await harness.emit("agent_end", { + messages: [ { - type: "toolCall", - id: "call-1", - name: "read", - arguments: {}, - incompleteArgumentsReason: "malformed", + role: "assistant", + content: testCase.content, + ...("omitUsage" in testCase ? {} : { usage: testCase.usage }), }, ], - usage: { totalTokens: 0 }, - }, - ] as const; - for (const testCase of cases) { - const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-terminal-malformed-${testCase.name}-`)); - try { - const harness = await invocationHarness(`terminal-malformed-${testCase.name}`, cwd, { - sendUserMessage: async (_content, options) => { - await options?.onPreflightAcceptCommit?.(); - await Promise.withResolvers().promise; - }, - }); - const accepted = await harness.control("turn.prompt", { text: "hello" }); - await harness.emit("agent_start"); - await harness.emit("agent_end", { - messages: [ - { - role: "assistant", - content: testCase.content, - ...("omitUsage" in testCase ? {} : { usage: testCase.usage }), - }, - ], - }); - expect( - await settledStatus(harness, "turn.result", { - kind: "prompt", - commandId: accepted.result?.commandId, - turnId: accepted.result?.turnId, - }), - ).toMatchObject({ - status: "failed", - error: { code: "prompt_failed", message: "Agent run failed after execution started." }, - outcome: { - kind: "failed", - code: "prompt_failed", - message: "Agent run failed after execution started.", - provenance: "agent_failed", - phase: "post_start", - category: "agent_runtime", - }, - }); - await harness.stop(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } + }); + expect( + await settledStatus(harness, "turn.result", { + kind: "prompt", + commandId: accepted.result?.commandId, + turnId: accepted.result?.turnId, + }), + ).toMatchObject({ + status: "failed", + error: { code: "prompt_failed", message: "Agent run failed after execution started." }, + outcome: { + kind: "failed", + code: "prompt_failed", + message: "Agent run failed after execution started.", + provenance: "agent_failed", + phase: "post_start", + category: "agent_runtime", + }, + }); + await harness.stop(); + } finally { + await rm(cwd, { recursive: true, force: true }); } - }); + }, 60_000); test("preserves explicit cancellation for an empty zero-token turn", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminal-empty-cancelled-")); try { @@ -7165,114 +7863,111 @@ describe("post-acceptance invocation terminalization", () => { await rm(cwd, { recursive: true, force: true }); } }); - test("reconciles contract-valid skill completion and agent_end ordering", async () => { - const cases = [ - { - name: "completion-real-agent-real", - order: "completion-first", - completion: "completion", - agent: "agent", - expected: "completion", - }, - { - name: "completion-blank-agent-real", - order: "completion-first", - completion: " ", - agent: "agent", - expected: "agent", - }, - { - name: "completion-none-agent-real", - order: "completion-first", - completion: null, - agent: "agent", - expected: "agent", - }, - { - name: "agent-real-completion-real", - order: "agent-first", - completion: "completion", - agent: "agent", - expected: "agent", - }, - { - name: "agent-real-completion-blank", - order: "agent-first", - completion: " ", - agent: "agent", - expected: "agent", - }, - { - name: "agent-blank-completion-real", - order: "agent-first", - completion: "completion", - agent: " ", - expected: "completion", - }, - { - name: "agent-blank-completion-none", - order: "agent-first", - completion: null, - agent: " ", - expected: undefined, - }, - { - name: "agent-none-completion-real", - order: "agent-first", - completion: "completion", - agent: null, - expected: "completion", - }, - { - name: "agent-none-completion-blank", - order: "agent-first", - completion: " ", - agent: null, - expected: undefined, - }, - ] as const; - for (const testCase of cases) { - const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-skill-terminal-order-${testCase.name}-`)); - const completion = Promise.withResolvers(); - const completionReconciled = Promise.withResolvers(); - try { - const harness = await invocationHarness(`skill-terminal-order-${testCase.name}`, cwd, { - onInvocationCompletionReconciled: kind => { - if (kind === "skill") completionReconciled.resolve(); - }, - invokeSkill: async (_name, _args, options) => { - await options?.onPreflightAcceptCommit?.(); - return await completion.promise; - }, + test.each([ + { + name: "completion-real-agent-real", + order: "completion-first", + completion: "completion", + agent: "agent", + expected: "completion", + }, + { + name: "completion-blank-agent-real", + order: "completion-first", + completion: " ", + agent: "agent", + expected: "agent", + }, + { + name: "completion-none-agent-real", + order: "completion-first", + completion: null, + agent: "agent", + expected: "agent", + }, + { + name: "agent-real-completion-real", + order: "agent-first", + completion: "completion", + agent: "agent", + expected: "agent", + }, + { + name: "agent-real-completion-blank", + order: "agent-first", + completion: " ", + agent: "agent", + expected: "agent", + }, + { + name: "agent-blank-completion-real", + order: "agent-first", + completion: "completion", + agent: " ", + expected: "completion", + }, + { + name: "agent-blank-completion-none", + order: "agent-first", + completion: null, + agent: " ", + expected: undefined, + }, + { + name: "agent-none-completion-real", + order: "agent-first", + completion: "completion", + agent: null, + expected: "completion", + }, + { + name: "agent-none-completion-blank", + order: "agent-first", + completion: " ", + agent: null, + expected: undefined, + }, + ] as const)("reconciles contract-valid skill completion and agent_end ordering ($name)", async testCase => { + const cwd = await mkdtemp(path.join(os.tmpdir(), `gjc-skill-terminal-order-${testCase.name}-`)); + const completion = Promise.withResolvers(); + const completionReconciled = Promise.withResolvers(); + try { + const harness = await invocationHarness(`skill-terminal-order-${testCase.name}`, cwd, { + onInvocationCompletionReconciled: kind => { + if (kind === "skill") completionReconciled.resolve(); + }, + invokeSkill: async (_name, _args, options) => { + await options?.onPreflightAcceptCommit?.(); + return await completion.promise; + }, + }); + const accepted = await harness.control("skill.invoke", { name: "ralplan" }); + const selector = { + kind: "skill" as const, + commandId: accepted.result?.commandId, + turnId: accepted.result?.turnId, + }; + await harness.emit("agent_start"); + const emitAgentEnd = () => + harness.emit("agent_end", { + messages: testCase.agent === null ? [] : [{ role: "assistant", content: testCase.agent }], }); - const accepted = await harness.control("skill.invoke", { name: "ralplan" }); - const selector = { - kind: "skill" as const, - commandId: accepted.result?.commandId, - turnId: accepted.result?.turnId, - }; - await harness.emit("agent_start"); - const emitAgentEnd = () => - harness.emit("agent_end", { - messages: testCase.agent === null ? [] : [{ role: "assistant", content: testCase.agent }], - }); - if (testCase.order === "completion-first") { - completion.resolve(testCase.completion); - await completionReconciled.promise; - await emitAgentEnd(); - } else { - await emitAgentEnd(); - completion.resolve(testCase.completion); - await completionReconciled.promise; - } - const result = await harness.query("turn.result", selector); - const content = (result.result as { content?: { text?: string } } | undefined)?.content; - expect(content?.text).toBe(testCase.expected); - await harness.stop(); - } finally { - completion.resolve(undefined); - await rm(cwd, { recursive: true, force: true }); + if (testCase.order === "completion-first") { + completion.resolve(testCase.completion); + await completionReconciled.promise; + await emitAgentEnd(); + } else { + await emitAgentEnd(); + completion.resolve(testCase.completion); + await completionReconciled.promise; } + const result = await harness.query("turn.result", selector); + const content = (result.result as { content?: { text?: string } } | undefined)?.content; + expect(content?.text).toBe(testCase.expected); + await harness.stop(); + } finally { + completion.resolve(undefined); + await rm(cwd, { recursive: true, force: true }); } }, 30_000); test("a queued follow-up prompt is not terminalized before the turn runs", async () => { @@ -8210,7 +8905,6 @@ describe("accepted-control zero-execution bound (#4668)", () => { promptDeadlineMs = 100; const queued = await harness.control("turn.follow_up", { text: "queued prompt" }); expect(queued.ok).toBe(true); - const activeIds = { commandId: active.result?.commandId, turnId: active.result?.turnId }; const queuedIds = { commandId: queued.result?.commandId, turnId: queued.result?.turnId }; // The short lease belongs to an unconsumed queue entry, not to the root // run: it stays accepted past two lease periods without borrowing root abort. @@ -8220,6 +8914,7 @@ describe("accepted-control zero-execution bound (#4668)", () => { expect(correlatedFrames(harness, queuedIds).filter(frame => frame.kind === "agent_failed")).toHaveLength(0); promoted?.({ startsOwnRun: true }); promptDeadlineMs = 600_000; + const activeIds = { commandId: active.result?.commandId, turnId: active.result?.turnId }; await Bun.sleep(150); expect((await harness.query("turn.prompt_status", queuedIds)).result?.status).toMatch(/accepted|in_flight/); expect(await harness.query("turn.prompt_status", activeIds)).toMatchObject({ @@ -10661,14 +11356,32 @@ test("SDK-only host advances a finalized stopped row when the retry replay match } }); -test.each([ - "hold", - "fail", - "removed-before-shutdown", - "timeout", - "failed-stop", -] as const)("SDK-only shutdown joins queued terminal publication (%s)", async mode => { +test.each( + (["text", "image"] as const).flatMap(kind => + (["hold", "fail", "removed-before-shutdown", "timeout", "failed-stop"] as const).map(mode => ({ kind, mode })), + ), +)("SDK-only shutdown joins queued terminal publication ($kind/$mode)", async ({ kind, mode }) => { const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-queued-shutdown-")); + const originalRedeem = PromptImageUploadStore.prototype.redeem; + let imageReleases = 0; + let redeemedImages: unknown; + const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function ( + this: PromptImageUploadStore, + owner, + ids, + ) { + const reservation = originalRedeem.call(this, owner, ids); + redeemedImages = reservation.images; + return { + images: reservation.images, + release: () => { + imageReleases++; + reservation.release(); + }, + }; + }); + let admittedContent: unknown; + let rootAbortCalls = 0; let removeQueued: (() => void) | undefined; const handlers = new Map Promise | void>(); let queued = false; @@ -10677,8 +11390,10 @@ test.each([ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise | void) { handlers.set(event, handler); }, - sendUserMessage: (_content: string, options: PreflightHooks | undefined) => + sendUserMessage: (content: unknown, options: PreflightHooks | undefined) => Promise.resolve(options?.onPreflightAcceptCommit?.()).then(() => { + admittedContent = content; + if (kind === "image") options?.onDispatchDisposition?.({ startsOwnRun: false }); queued = true; queueSignal = options?.preflightSignal; removeQueued = () => { @@ -10691,7 +11406,23 @@ test.each([ return {}; }), } as unknown as ExtensionAPI; - const transport = memoryTransport(); + const memory = memoryTransport(); + let transportOpen = false; + const startMemory = memory.start.bind(memory); + const stopMemory = memory.stop.bind(memory); + const transport = { + ...memory, + isConnectionOpen: (connectionId: string) => transportOpen && connectionId === "requester", + start: async () => { + const endpoint = await startMemory(); + transportOpen = true; + return endpoint; + }, + stop: async () => { + await stopMemory(); + transportOpen = false; + }, + }; let transportStops = 0; const originalStop = transport.stop.bind(transport); const stopSpy = spyOn(transport, "stop").mockImplementation(async () => { @@ -10713,7 +11444,10 @@ test.each([ getTerminalTurnEpoch: () => 7, getActivePromptHandle: () => "unrelated-handle", cancelPendingPreflightForTerminalAbort: () => {}, - abortPromptAndWaitWithTerminal: async () => ({ status: "settled", terminalScope: {} }), + abortPromptAndWaitWithTerminal: async () => { + rootAbortCalls++; + throw new Error("Queued shutdown must not borrow an unrelated root terminal"); + }, }, }); const ctx = { ...extensionContext(transport.sessionId, cwd), isIdle: () => true } as unknown as ExtensionContext; @@ -10724,11 +11458,13 @@ test.each([ let commandId: string | undefined; let armed = false; let interrupted = false; + const releasesAtTerminalAttempt: number[] = []; const fault = spyOn(fsPromises, "rename").mockImplementation(async (from, to) => { if (armed && String(to) === target) { const document = (await Bun.file(String(from)).json()) as ReconciliationStoreDocument; if (document.records.some(record => record.commandId === commandId && record.terminalAt !== undefined)) { interrupted = true; + releasesAtTerminalAttempt.push(imageReleases); started.resolve(); if (mode === "fail" || mode === "failed-stop") throw Object.assign(new Error("Injected queued terminal EIO"), { code: "EIO" }); @@ -10747,11 +11483,49 @@ test.each([ let shutdown: Promise | undefined; try { await handlers.get("session_start")?.({}, ctx); + let imageId: string | undefined; + let imageBytes: Buffer | undefined; + if (kind === "image") { + imageBytes = Buffer.from( + await Bun.file(new URL("../../../test/fixtures/sdk-inline-image-large.png", import.meta.url)).arrayBuffer(), + ); + expect(imageBytes.length).toBeGreaterThan(256 * 1024); + const control = async ( + id: string, + operation: string, + input: Record, + ): Promise => { + const frame = { type: "control_request", id, operation, input } as SdkFrame; + expect(Buffer.byteLength(JSON.stringify(frame))).toBeLessThan(256 * 1024); + transport.feed("requester", frame); + await waitFor(() => transport.sent.some(response => response.id === id), operation); + const response = transport.sent.find(response => response.id === id) as ResponseFrame; + expect(response.ok).toBe(true); + return response; + }; + const begun = await control("image-begin", "turn.image.begin", { + mimeType: "image/png", + byteLength: imageBytes.length, + sha256: createHash("sha256").update(imageBytes).digest("hex"), + }); + imageId = begun.result?.id; + if (!imageId) throw new Error("Host did not return a staged image ID"); + let sequence = 0; + for (let offset = 0; offset < imageBytes.length; offset += 96 * 1024) { + await control(`image-append-${sequence}`, "turn.image.append", { + id: imageId, + sequence, + data: imageBytes.subarray(offset, offset + 96 * 1024).toString("base64"), + }); + sequence++; + } + await control("image-finish", "turn.image.finish", { id: imageId }); + } transport.feed("requester", { type: "control_request", id: "followup", - operation: "turn.follow_up", - input: { text: "unconsumed followup" }, + operation: kind === "image" ? "turn.prompt" : "turn.follow_up", + input: { text: "unconsumed followup", ...(imageId ? { stagedImages: [{ id: imageId }] } : {}) }, } as SdkFrame); await waitFor(() => transport.sent.some(frame => frame.id === "followup"), "follow-up admission"); const accepted = transport.sent.find(frame => frame.id === "followup") as ResponseFrame; @@ -10759,6 +11533,19 @@ test.each([ commandId = accepted.result?.commandId; expect(commandId).toBeDefined(); await waitFor(() => queued && queueSignal !== undefined, "queued follow-up"); + expect(imageReleases).toBe(0); + if (kind === "image") { + expect(redeemedImages).toEqual([ + { type: "image", data: imageBytes!.toString("base64"), mimeType: "image/png" }, + ]); + expect(admittedContent).toEqual([ + { type: "text", text: "unconsumed followup" }, + ...(redeemedImages as unknown[]), + ]); + } else { + expect(redeemedImages).toBeUndefined(); + expect(admittedContent).toBe("unconsumed followup"); + } armed = true; if (mode === "removed-before-shutdown") { removeQueued?.(); @@ -10776,6 +11563,8 @@ test.each([ ); await started.promise; expect(interrupted).toBe(true); + expect(releasesAtTerminalAttempt[0]).toBe(0); + expect(rootAbortCalls).toBe(0); expect(queueSignal?.aborted).toBe(true); expect(queued).toBe(false); if (mode === "fail" || mode === "timeout" || mode === "failed-stop") { @@ -10812,6 +11601,7 @@ test.each([ await Bun.sleep(25); expect(settled).toBe(false); expect(transportStops).toBe(0); + expect(imageReleases).toBe(0); transport.feed("requester", { type: "control_request", id: "draining-followup", @@ -10831,11 +11621,14 @@ test.each([ const durable = (await Bun.file(target).json()) as ReconciliationStoreDocument; expect(durable.records.find(record => record.commandId === commandId)?.terminalAt).toBeDefined(); } + expect(imageReleases).toBe(kind === "image" ? 1 : 0); + expect(rootAbortCalls).toBe(0); } finally { release.resolve(); await shutdown?.catch(() => undefined); fault.mockRestore(); stopSpy.mockRestore(); + redeemSpy.mockRestore(); await rm(cwd, { recursive: true, force: true }); } }); diff --git a/packages/coding-agent/src/sdk/host/session-runtime.ts b/packages/coding-agent/src/sdk/host/session-runtime.ts index e99fe8873f3..e4c6e57f520 100644 --- a/packages/coding-agent/src/sdk/host/session-runtime.ts +++ b/packages/coding-agent/src/sdk/host/session-runtime.ts @@ -123,6 +123,7 @@ import { TURN_STREAM_CAPABILITY, } from "./host"; import { clearAutoroutingInactive, isAutoroutingInactive, markAutoroutingInactive } from "./internal-autorouting-state"; +import { PromptImageUploadStore } from "./prompt-image-upload"; import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "./query"; import { createSdkRunCapability } from "./sdk-run-capability"; import { @@ -348,6 +349,8 @@ export interface SessionSdkTransport { stop(): Promise; broadcastFrame?(frame: SdkFrame): void; onConnectionClose?(handler: (connectionId: string) => void): undefined | (() => void); + /** Authoritative transport socket membership, checked when queued image work executes. */ + isConnectionOpen?(connectionId: string): boolean; onNegotiatedCapabilities?( handler: (connectionId: string, capabilities: readonly string[]) => void, ): undefined | (() => void); @@ -355,6 +358,7 @@ export interface SessionSdkTransport { export interface SessionSdkRuntimeOptions extends Omit { + onDisconnected?: (connectionId: string) => void; transport: SessionSdkTransport; /** Session settings; enables `config.patch` application on this runtime. */ settings?: Settings; @@ -517,6 +521,8 @@ export class SessionSdkSessionRuntime { }, onFrame: handler => options.transport.onFrame((connectionId, frame) => { + // Stock WebSocket transport only dispatches frames while its socket is + // registered; close removes the socket before invoking onConnectionClose. this.#connectionIds.add(connectionId); handler(connectionId, frame); }), @@ -524,6 +530,7 @@ export class SessionSdkSessionRuntime { this.#connectionDisposer = options.transport.onConnectionClose?.(connectionId => { this.#connectionIds.delete(connectionId); this.#connectionCapabilities.delete(connectionId); + options.onDisconnected?.(connectionId); this.host.handleDisconnect(connectionId); }); this.#capabilitiesDisposer = options.transport.onNegotiatedCapabilities?.((connectionId, negotiated) => { @@ -790,7 +797,6 @@ function retireAcceptedQueueCancellation( cancellation.resolveDisposition("teardown"); } } - export type InvocationKind = "prompt" | "skill" | "steer"; type InvocationStatus = "accepted" | "in_flight" | "terminal_ok" | "failed" | "uncertain"; type InvocationOutcome = SdkPromptTerminalOutcome; @@ -2913,6 +2919,8 @@ function createControlSurface( ) => void, armPromptDeadline: (correlation: InvocationCorrelation) => void, steerReconciliation: KindAwareReconciliation, + imageUploads: PromptImageUploadStore, + hasCapturedInvocationTerminal: (kind: InvocationKind, correlation: InvocationCorrelation) => boolean, onPromotedTurn?: ( kind: InvocationKind, correlation: InvocationCorrelation, @@ -2940,6 +2948,8 @@ function createControlSurface( trackGateResolution: (resolution: Promise) => Promise = async resolution => await resolution, onInvocationCompletionReconciledForTests?: (kind: InvocationKind, correlation: InvocationCorrelation) => void, publishLifecycleFrame?: (frame: SdkFrame) => void, + retainAcceptedImage?: (correlation: InvocationCorrelation, release: () => void) => void, + releaseAcceptedImage?: (correlation: InvocationCorrelation) => void, acceptedQueueCancellations: Map = new Map(), ): ControlSurface { const normalizePromptImages = (value: unknown): ImageContent[] => { @@ -3113,6 +3123,7 @@ function createControlSurface( acceptedFields?: () => Record, allowCompletionFallback = false, alwaysQueued = false, + onCreated?: (correlation: InvocationCorrelation) => void, ): Promise => { // Capture the REQUESTING connection at admission: a terminal abort from // another SDK connection must never stop the prompt this one accepts @@ -3121,9 +3132,11 @@ function createControlSurface( const retainedClientRef = normalizeClientRef(clientRef); reconciliation.admit(kind, retainedClientRef); const correlation = newCorrelation(); + onCreated?.(correlation); const sdkRunToken = `${correlation.commandId}:${correlation.turnId}`; const sdkRunCapability = createSdkRunCapability(sdkRunToken); const publishTerminal = (outcome: InvocationOutcome): void => { + releaseAcceptedImage?.(correlation); retireAcceptedQueueCancellation(acceptedQueueCancellations, correlation); publishLifecycleFrame?.({ type: "agent_end", @@ -3282,16 +3295,18 @@ function createControlSurface( void submission.then( result => { if (settled) { - // A resolved submission after preflight acceptance means the work is over - // for every kind. `noteTransition` ignores an already-terminal record, so - // terminalizing here is safe — unless the submission resolved at queue time - // (followUp, or a prompt diverted to steer while streaming), in which case - // the turn's own lifecycle events drive terminalization. + // Queue admission is not terminal authority. A captured actual end + // also retains its own durable repair owner; fallback below is only + // for completion without that exact producer boundary. // Dispatch-race P1: queuedAtDispatch is the pre-dispatch snapshot; // a delayed preflight diverted to steering fires onQueuedPromoted // with startsOwnRun:false and is now attached to the in-flight run. // Do not terminalize from the stale snapshot — the run will. if (!queuedAtDispatch && promotionStartsOwnRun !== false) { + // The exact actual end may still be repairing its durable write. + // Submission settlement cannot replace that producer's outcome + // or release its recovery lease with a bare completion. + if (hasCapturedInvocationTerminal(kind, correlation)) return; // The accepted work settled without its own run still pending: // retire the pending ownership entry (and with it the // acceptance-anchored deadline lease, #4668 review) BEFORE @@ -3361,14 +3376,28 @@ function createControlSurface( }, error => { if (settled) { + if (hasCapturedInvocationTerminal(kind, correlation)) { + const record = reconciliation.lookup(kind, correlation) as { status?: string }; + logger.warn( + record.status === "terminal_ok" || record.status === "failed" + ? "SDK submission cleanup failed after terminal publication" + : "SDK submission cleanup failed after owned terminal capture", + { + kind, + commandId: correlation.commandId, + turnId: correlation.turnId, + error: sanitizePromptFailure(error), + }, + ); + return; + } + releaseAcceptedImage?.(correlation); retireAcceptedQueueCancellation(acceptedQueueCancellations, correlation); - // The submission promise rejects after preflight acceptance only when the - // work itself is over (provider stream interrupt, abort, queue failure). - // The accepted run never started (agent_start never fired), so its pending - // entry must be retired — otherwise a later agent-initiated - // monitor/cron turn's agent_start would shift the stale entry and - // associate the failed submission's connection as owner (review - // thread P1). + // No exact actual terminal was captured. The rejection fallback + // owns this settlement and must retire its pending admission so a + // later agent-initiated turn cannot borrow the failed requester's + // connection. Captured terminals return above without replacing + // their outcome or releasing their durable recovery lease. retirePendingOwner?.(kind, correlation); // agent_failed alone is diagnostic-only (agent_end is the // terminal boundary), so the failure reason is recorded first @@ -3482,6 +3511,8 @@ function createControlSurface( ...(acceptedFields?.() ?? {}), }; } catch (error) { + if (!accepted) preflightController.abort(); + releaseAcceptedImage?.(correlation); retireAcceptedQueueCancellation(acceptedQueueCancellations, correlation); if (!accepted) reconciliation.release(kind, retainedClientRef); throw error; @@ -4400,28 +4431,58 @@ function createControlSurface( } }; return { - prompt: async (text, images, clientRef) => { - const invalid = validateRequiredPromptText("turn.prompt", { text, images }); + prompt: async (text, images, clientRef, stagedImages) => { + const invalid = validateRequiredPromptText("turn.prompt", { text, images, stagedImages }); if (invalid) throw Object.assign(new Error(invalid.message), { code: invalid.code }); - return await submit("prompt", clientRef, ({ queuedAtDispatch, sdkRunCapability, ...options }) => - sendSdkUserMessage( - typeof images === "undefined" - ? text - : ([{ type: "text", text }, ...normalizePromptImages(images)] as [ - { type: "text"; text: string }, - ...ImageContent[], - ]), - { - ...options, - sdkRunCapability, - // ACP terminal settlement is owned by the correlated agent_end - // publication. Post-prompt recovery may include independent - // subagent work and must not hold that client-facing boundary. - ...(queuedAtDispatch ? { queuedAtDispatch: true } : {}), + if (stagedImages !== undefined && images !== undefined) + throw Object.assign(new Error("Direct and staged images cannot be mixed."), { code: "invalid_input" }); + if (stagedImages !== undefined && !imageUploads) + throw Object.assign(new Error("Image uploads are unavailable."), { code: "operation_prohibited" }); + const reservation = + stagedImages === undefined + ? undefined + : imageUploads!.redeem(sdkControlRequesterContext.getStore(), stagedImages); + try { + return await submit( + "prompt", + clientRef, + ({ queuedAtDispatch, sdkRunCapability, ...options }) => + sendSdkUserMessage( + typeof images === "undefined" && !reservation + ? text + : ([{ type: "text", text }, ...(reservation?.images ?? normalizePromptImages(images))] as [ + { type: "text"; text: string }, + ...ImageContent[], + ]), + { + ...options, + sdkRunCapability, + // ACP terminal settlement is owned by the correlated agent_end + // publication. Post-prompt recovery may include independent + // subagent work and must not hold that client-facing boundary. + ...(queuedAtDispatch ? { queuedAtDispatch: true } : {}), + }, + ), + undefined, + false, + false, + correlation => { + if (reservation) retainAcceptedImage?.(correlation, reservation.release); }, - ), - ); + ); + } catch (error) { + reservation?.release(); + throw error; + } }, + ...(imageUploads + ? { + imageBegin: (input: unknown) => imageUploads.begin(sdkControlRequesterContext.getStore(), input), + imageAppend: (input: unknown) => imageUploads.append(sdkControlRequesterContext.getStore(), input), + imageFinish: (input: unknown) => imageUploads.finish(sdkControlRequesterContext.getStore(), input), + imageDiscard: (input: unknown) => imageUploads.discard(sdkControlRequesterContext.getStore(), input), + } + : {}), steer: async (text, clientRef, expectedSdkRunToken) => { const invalid = validateRequiredPromptText("turn.steer", { text }); if (invalid) throw Object.assign(new Error(invalid.message), { code: invalid.code }); @@ -4676,7 +4737,9 @@ function createControlSurface( retryLast: () => typed("retry.last"), retryNow: () => typed("retry.now"), backgroundBash: () => typed("bash.background"), - installedOperations: surfacePolicy.installedControls, + installedOperations: imageUploads + ? surfacePolicy.installedControls + : new Set([...surfacePolicy.installedControls].filter(operation => !operation.startsWith("turn.image."))), revisionProvider: resource => (resource === "config" ? String(configRevision.current) : undefined), }; } @@ -4826,7 +4889,14 @@ function quiescingFrame(frame: Record): Record /** Install a complete SDK host for a session when notifications are inactive. */ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: CreateSdkSessionRuntimeOptions): void { + const acceptedImages = new Map void>(); const acceptedQueueCancellations = new Map(); + const imageKey = (correlation: InvocationCorrelation): string => `${correlation.commandId}:${correlation.turnId}`; + const releaseAcceptedImage = (correlation: InvocationCorrelation): void => { + const key = imageKey(correlation); + acceptedImages.get(key)?.(); + acceptedImages.delete(key); + }; const disposeAcceptedQueueCancellations = async (): Promise => { const cancellations = [...acceptedQueueCancellations.values()]; const queuedRemovals = cancellations.filter( @@ -4881,6 +4951,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre { code: "sdk_reconciliation_teardown_failed" }, ); }; + let currentImageUploads: PromptImageUploadStore | undefined; let active: | { sessionId: string; @@ -4964,6 +5035,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre const skillRecoveryControllers = new Map(); const skillTerminalRecoveryControllers = new Map(); const ambiguousLifecycleIdentities = new Set(); + const capturedPromptTerminals = new WeakSet(); const lifecycleRunOwners = new Map< string, { state: RuntimeState; batch?: LifecycleBatch; correlationKey?: string } @@ -5977,8 +6049,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre } if (type === "agent_end" && isContinuingMidRunMaintenanceOutcome(maintenanceOutcome)) return; if (type === "agent_end") { - for (const invocation of transitions) + for (const invocation of transitions) { + releaseAcceptedImage(invocation.correlation); retireAcceptedQueueCancellation(acceptedQueueCancellations, invocation.correlation); + } if (current.lifecycleEpoch !== eventLifecycleEpoch) { // A successor agent_start won the lifecycle race while this event's // durable transitions were awaiting persistence. Retire the ended @@ -6128,6 +6202,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre event.stopReason === "maintenance" ? event.maintenanceOutcome : undefined, ) : canonicalFailedOutcome(EMPTY_PROMPT_FAILURE); + // Capture exact private invocation objects before any durable write yields. + // Weak keys retain no completed invocation and cannot attest a foreign row. + for (const invocation of failureCandidates) + if (invocation.kind === "prompt") capturedPromptTerminals.add(invocation.correlation); const releaseTerminalRetention = retainTerminalBoundaries(failureCandidates); return trackLifecycle(async () => { for (const invocation of failureCandidates) { @@ -6819,7 +6897,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre }); // 3. Release recovery ownership ONLY after durable terminalization. deadlineManager.clear(correlation); - if (kind === "prompt") retireAcceptedQueueCancellation(acceptedQueueCancellations, correlation); + if (kind === "prompt") { + releaseAcceptedImage(correlation); + retireAcceptedQueueCancellation(acceptedQueueCancellations, correlation); + } return true; } catch (transitionError) { // Keep prompt recovery leased; skill recovery has no prompt lease. @@ -6837,6 +6918,12 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre return attempt(3); }; + // Never fall back to the runtime's frame-observer set: it is not the + // transport's authoritative socket membership. Missing liveness fails closed. + const imageUploads = new PromptImageUploadStore( + connectionId => transport.isConnectionOpen?.(connectionId) === true, + ); + currentImageUploads = imageUploads; const controlSurface = createControlSurface( ctx, api, @@ -6873,6 +6960,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre }, correlation => deadlineManager.onAccepted(correlation), steerReconciliation, + imageUploads, + (kind, correlation) => kind === "prompt" && capturedPromptTerminals.has(correlation), (kind, correlation, connectionId, sdkRunToken, promotion) => { const bindPromotedToken = (batch?: LifecycleBatch): void => { const owner = lifecycleOwnerHolder.state; @@ -7063,6 +7152,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre trackGateResolution, options.onInvocationCompletionReconciledForTests, frame => runtime.emitEvent(frame), + (correlation, release) => acceptedImages.set(imageKey(correlation), release), + releaseAcceptedImage, acceptedQueueCancellations, ); const installProviderDefinitions = (capability: string, definitions: unknown): void => { @@ -7131,6 +7222,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre }; runtime = new SessionSdkSessionRuntime({ transport, + onDisconnected: connectionId => imageUploads.disconnect(connectionId), eventRevision: () => typeof (ctx as Partial).getTranscript === "function" ? ctx.getTranscript().length @@ -7576,6 +7668,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre } catch (cleanupError) { queueCancellationFailure = cleanupError; } + imageUploads.close(); + if (currentImageUploads === imageUploads) currentImageUploads = undefined; + for (const release of acceptedImages.values()) release(); + acceptedImages.clear(); stopBrokerRecovery(); disposeGate?.(); try { @@ -7649,6 +7745,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre } catch (cleanupError) { queueCancellationFailure = cleanupError; } + currentImageUploads?.close(); + currentImageUploads = undefined; + for (const release of acceptedImages.values()) release(); + acceptedImages.clear(); if (cancelSkillRecovery) { for (const controller of skillRecoveryControllers.values()) controller.abort(); for (const controller of skillTerminalRecoveryControllers.values()) controller.abort(); diff --git a/packages/coding-agent/src/sdk/host/websocket-transport.ts b/packages/coding-agent/src/sdk/host/websocket-transport.ts index e921fceafde..9726a896a75 100644 --- a/packages/coding-agent/src/sdk/host/websocket-transport.ts +++ b/packages/coding-agent/src/sdk/host/websocket-transport.ts @@ -167,6 +167,9 @@ export async function createSdkWebSocketTransport( if (!socket) throw new Error("SDK connection is no longer available."); socket.send(JSON.stringify(frame)); }, + isConnectionOpen(connectionId) { + return sockets.has(connectionId); + }, start: async () => { if (stopPromise) await stopPromise; if (startPromise) return await startPromise; diff --git a/packages/coding-agent/src/sdk/protocol/adapter-validation.ts b/packages/coding-agent/src/sdk/protocol/adapter-validation.ts index cecdeb0bde9..cbcf7bfc4a3 100644 --- a/packages/coding-agent/src/sdk/protocol/adapter-validation.ts +++ b/packages/coding-agent/src/sdk/protocol/adapter-validation.ts @@ -40,10 +40,18 @@ function hasUsablePromptImage(value: unknown): boolean { ); } +function hasStagedPromptImage(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const image = value as Record; + return typeof image.id === "string" && image.id.length > 0; +} + /** Rejects instruction-bearing controls before correlation or provider work. */ export function validateRequiredPromptText(operation: string, input: Input): AdapterValidationError | undefined { const hasUsableImage = - operation === "turn.prompt" && Array.isArray(input.images) && input.images.some(hasUsablePromptImage); + operation === "turn.prompt" && + ((Array.isArray(input.images) && input.images.some(hasUsablePromptImage)) || + (Array.isArray(input.stagedImages) && input.stagedImages.some(hasStagedPromptImage))); if ( PROMPT_OPERATIONS.has(operation) && (typeof input.text !== "string" || (input.text.trim().length === 0 && !hasUsableImage)) diff --git a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json index e86527de1fe..dc3f2da2099 100644 --- a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json +++ b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json @@ -953,6 +953,78 @@ "packages/coding-agent/test/sdk-operation-inventory.test.ts" ] }, + { + "sourceId": "registry:C54", + "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts", + "sourceKind": "registry", + "decision": "include", + "sdkId": "turn.image.begin", + "adapterMappings": { + "telegram": "prohibited", + "discord": "prohibited", + "slack": "prohibited", + "mcp": "prohibited", + "acp": "machine_only", + "daemonCli": "machine_only" + }, + "testIds": [ + "packages/coding-agent/test/sdk-operation-inventory.test.ts" + ] + }, + { + "sourceId": "registry:C55", + "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts", + "sourceKind": "registry", + "decision": "include", + "sdkId": "turn.image.append", + "adapterMappings": { + "telegram": "prohibited", + "discord": "prohibited", + "slack": "prohibited", + "mcp": "prohibited", + "acp": "machine_only", + "daemonCli": "machine_only" + }, + "testIds": [ + "packages/coding-agent/test/sdk-operation-inventory.test.ts" + ] + }, + { + "sourceId": "registry:C56", + "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts", + "sourceKind": "registry", + "decision": "include", + "sdkId": "turn.image.finish", + "adapterMappings": { + "telegram": "prohibited", + "discord": "prohibited", + "slack": "prohibited", + "mcp": "prohibited", + "acp": "machine_only", + "daemonCli": "machine_only" + }, + "testIds": [ + "packages/coding-agent/test/sdk-operation-inventory.test.ts" + ] + }, + { + "sourceId": "registry:C57", + "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts", + "sourceKind": "registry", + "decision": "include", + "sdkId": "turn.image.discard", + "adapterMappings": { + "telegram": "prohibited", + "discord": "prohibited", + "slack": "prohibited", + "mcp": "prohibited", + "acp": "machine_only", + "daemonCli": "machine_only" + }, + "testIds": [ + "packages/coding-agent/test/sdk-operation-inventory.test.ts" + ] + }, { "sourceId": "registry:G01", "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts", diff --git a/packages/coding-agent/src/sdk/protocol/operation-registry.ts b/packages/coding-agent/src/sdk/protocol/operation-registry.ts index e2430d52d39..0df122cb833 100644 --- a/packages/coding-agent/src/sdk/protocol/operation-registry.ts +++ b/packages/coding-agent/src/sdk/protocol/operation-registry.ts @@ -125,6 +125,10 @@ const controls = [ ["retry.now", "Immediately retry pending backoff."], ["bash.background", "Move active managed bash to the background."], ["model.profile.set", "Activate a model profile for the current session."], + ["turn.image.begin", "Begin a bounded host-owned image upload."], + ["turn.image.append", "Append a canonical base64 image chunk."], + ["turn.image.finish", "Verify and stage a complete image."], + ["turn.image.discard", "Discard an unfinished or staged image."], ] as const; const globals = [ @@ -230,6 +234,15 @@ const reverse = [ ] as const; function controlDisposition(id: string): Record { + if (["C54", "C55", "C56", "C57"].includes(id)) + return dispositions({ + telegram: "prohibited", + discord: "prohibited", + slack: "prohibited", + mcp: "prohibited", + acp: "machine_only", + daemonCli: "machine_only", + }); if (["C25", "C26", "C27", "C28", "C29", "C30", "C31", "C32", "C34", "C48"].includes(id)) return dispositions({ telegram: "prohibited", @@ -289,6 +302,10 @@ function controlErrors(id: string): string[] { C50: ["nothing_to_retry", "busy"], C51: ["retry_not_pending"], C52: ["not_foldable", "already_backgrounded", "no_active_bash"], + C54: ["invalid_input", "busy", "resource_gone", "operation_prohibited"], + C55: ["invalid_input", "busy", "resource_gone", "operation_prohibited"], + C56: ["invalid_input", "busy", "resource_gone", "operation_prohibited"], + C57: ["invalid_input", "resource_gone", "operation_prohibited"], }; return errors[id] ?? ["invalid_request", "busy"]; } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index e8ab9292bee..5ba3bf38453 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -3450,6 +3450,8 @@ export class AgentSession { if (sdkRunToken !== undefined) { this.#activeSdkRunToken = sdkRunToken; this.#sdkRunTokensByAttemptScope.set(handle.scope, sdkRunToken); + const publicationWaiter = this.#sdkRunPublicationWaiters.get(sdkRunToken); + if (publicationWaiter) publicationWaiter.accepted = true; const inheritedCohort = predecessorScope !== undefined && predecessorSdkRunToken === sdkRunToken ? this.#sdkRunCohortsByAttemptScope.get(predecessorScope) @@ -3796,6 +3798,16 @@ export class AgentSession { #sdkRunTokensByQueuedMessage = new WeakMap(); #skipPostPromptRecoveryWaitByAttemptScope = new WeakSet(); #sdkRunTokensByAttemptScope = new WeakMap(); + #sdkRunPublicationWaiters = new Map< + string, + { + accepted: boolean; + failed: boolean; + error: unknown; + promise: Promise; + resolve: () => void; + } + >(); #sdkRunCohortsByAttemptScope = new WeakMap(); #lifecycleScopesByAttemptScope = new WeakMap(); #activeSdkRunToken: string | undefined; @@ -4016,6 +4028,8 @@ export class AgentSession { // keep waiting on them; their own finally still runs if the stream ever ends. readonly #abandonedInFlightPrompts = new Set(); #agentEventHandlersInFlight = 0; + readonly #agentEventHandlersByScope = new Map(); + readonly #sdkTerminalPublications = new Set(); #queuedExtensionEventCount = 0; #extensionTurnGeneration = 0; #closedExtensionTurnGeneration: number | undefined; @@ -4023,6 +4037,12 @@ export class AgentSession { // async event handlers settle. Subscribers treat agent_end as readiness, so // publishing it earlier lets a successor corrupt the prior prompt's lifecycle. #pendingAgentEndEmit: AgentSessionEvent | undefined; + #deferredSdkAttemptFailures = new WeakMap(); + #cancelledSdkRecoveryEnds = new WeakMap(); // A scheduled continuation owns this terminal boundary until it either starts // the successor or proves it cannot. Holds prevent a false idle event while // preserving the predecessor for cancellation and preflight failures. @@ -5100,14 +5120,32 @@ export class AgentSession { if (errors.length > 1) throw new AggregateError(errors, "Multiple deferred prompt messages failed to flush"); } + #canPublishAgentEnd(event: AgentSessionEvent): boolean { + const scope = (event as AgentSessionEvent & { scope?: AttemptScope }).scope; + if (this.#livePromptsInFlight() > 0) return false; + const sdkRunToken = scope === undefined ? undefined : this.#sdkRunTokensByAttemptScope.get(scope); + if (sdkRunToken !== undefined) { + for (const [handlerScope, count] of this.#agentEventHandlersByScope) + if (count > 0 && this.#sdkRunTokensByAttemptScope.get(handlerScope) === sdkRunToken) return false; + for (const held of this.#pendingAgentEndContinuationHolds.values()) { + const heldScope = (held as AgentSessionEvent & { scope?: AttemptScope }).scope; + if (held === event || (heldScope !== undefined && this.#sdkRunTokensByAttemptScope.get(heldScope) === sdkRunToken)) return false; + } + return true; + } + return this.#agentEventHandlersInFlight === 0 && this.#pendingAgentEndContinuationHolds.size === 0; + } + + #hasSdkQueuedTerminalBarrier(): boolean { + if (this.#sdkTerminalPublications.size > 0) return true; + const pending = this.#pendingAgentEndEmit; + const scope = (pending as (AgentSessionEvent & { scope?: AttemptScope }) | undefined)?.scope; + return pending !== undefined && scope !== undefined && this.#sdkRunTokensByAttemptScope.has(scope) && this.#deferredAgentEndLeases.has(pending); + } + #flushPendingAgentEnd(): void { - if ( - this.#livePromptsInFlight() > 0 || - this.#agentEventHandlersInFlight > 0 || - this.#pendingAgentEndContinuationHolds.size > 0 - ) - return; const pending = this.#pendingAgentEndEmit; + if (pending && !this.#canPublishAgentEnd(pending)) return; if (!pending) { this.#resolveSessionSettlement(); return; @@ -5124,6 +5162,7 @@ export class AgentSession { | AttemptScope | undefined; const sdkTerminal = pendingScope !== undefined && this.#sdkRunTokensByAttemptScope.has(pendingScope); + if (sdkTerminal) this.#sdkTerminalPublications.add(pending); this.#agentEndPublicationPromise = this.#publishDeferredAgentEnd(pending, lease, sdkTerminal); void this.#agentEndPublicationPromise; } @@ -5141,6 +5180,13 @@ export class AgentSession { const publicationCorrelationId = publicationScope ? this.#sdkRunTokensByAttemptScope.get(publicationScope) : undefined; + const publicationSdkRunTokens = publicationScope + ? this.#sdkRunCohortsByAttemptScope.get(publicationScope) ?? + (publicationCorrelationId ? [publicationCorrelationId] : []) + : []; + const publicationWaiters = publicationSdkRunTokens + .map(token => this.#sdkRunPublicationWaiters.get(token)) + .filter(waiter => waiter?.accepted); let extensionDelivery: Promise | undefined; const releaseLease = () => { if (!lease) return; @@ -5150,6 +5196,7 @@ export class AgentSession { } lease.closeDiscovery(); }; + let terminalEmitted = false; const publish = async () => { let workerIntegration: Promise | undefined; let workerIntegrationOutcome: WorkerIntegrationOutcome | undefined; @@ -5168,9 +5215,40 @@ export class AgentSession { // and busy input state visible after the model has already finished. The queue // preserves transaction order; terminal publication is the user-visible // authority and must not be suppressed by a secondary persistence failure. + const cancelledRecovery = this.#cancelledSdkRecoveryEnds.get(pending); + if ( + cancelledRecovery && + pending.type === "agent_end" && + publicationScope === cancelledRecovery.scope && + publicationCorrelationId === cancelledRecovery.sdkRunToken && + lease === cancelledRecovery.lease + ) { + // This is the admitted retry owner's cancellation disposition for + // this genuine, already-held end and its claimed producer. Preserve + // object-keyed authority and private failure evidence; do not replay + // a terminal or expose the abandoned attempt as the final diagnostic. + pending.stopReason = "cancelled"; + pending.messages = []; + this.#cancelledSdkRecoveryEnds.delete(pending); + } const terminalPersistence = this.#queueCoordinatorRuntimeStatePersist(pending, true); + const attemptFailure = publicationScope ? this.#deferredSdkAttemptFailures.get(publicationScope) : undefined; + if ( + attemptFailure && + publicationScope && + pending.type === "agent_end" && + (pending.stopReason === "maintenance" + ? pending.maintenanceOutcome === "failed" + : pending.stopReason !== "cancelled" && + pending.messages.findLast(message => message.role === "assistant")?.stopReason === "error") + ) { + this.#emit(attemptFailure); + await this.#emitExtensionEvent(attemptFailure); + this.#deferredSdkAttemptFailures.delete(publicationScope); + } this.#settleTrackedQueuedInputTerminal(publicationScope); this.#emit(pending); + terminalEmitted = true; void terminalPersistence.then( () => { if (workerIntegrationOutcome) { @@ -5233,11 +5311,25 @@ export class AgentSession { try { if (lease) await this.#runResourceLeaseContext.run(lease, publish); else await publish(); + for (const waiter of publicationWaiters) waiter?.resolve(); + } catch (error) { + for (const waiter of publicationWaiters) { + if (!waiter) continue; + waiter.failed = true; + waiter.error = error; + waiter.resolve(); + } + throw error; } finally { if (extensionDelivery) void extensionDelivery.then(releaseLease, releaseLease); else releaseLease(); this.#agentEndPublicationInFlight = Math.max(0, this.#agentEndPublicationInFlight - 1); + const sdkPublication = this.#sdkTerminalPublications.delete(pending); this.#resolveSessionSettlement(); + if (sdkPublication && terminalEmitted && !this.#isDisposed && !this.#sessionAdmissionClosing) { + this.#releaseDeferredSdkFollowUps(); + if (this.agent.hasQueuedMessages()) this.#scheduleQueuedDelivery(); + } } } @@ -6969,9 +7061,10 @@ export class AgentSession { if ((event.type === "agent_start" || event.type === "turn_start") && eventScope !== undefined) { this.#bindAttemptScopeToActiveRun(eventScope); } + const handlerScope = eventScope ?? this.#activeAttemptScope; this.#agentEventAdmission.set(event, { - scope: this.#activeAttemptScope, - sdkRunToken: this.#activeSdkRunToken, + scope: handlerScope, + sdkRunToken: eventScope === undefined ? this.#activeSdkRunToken : this.#sdkRunTokensByAttemptScope.get(eventScope), persistGeneration: this.#coordinatorPersistGeneration, persistBarrier: this.#coordinatorRescopeBarrier, }); @@ -7023,6 +7116,7 @@ export class AgentSession { const agentEndHandled = event.type === "agent_end" ? Promise.withResolvers() : undefined; if (agentEndHandled) this.#agentEndHandlingPromise = agentEndHandled.promise; this.#agentEventHandlersInFlight++; + if (handlerScope) this.#agentEventHandlersByScope.set(handlerScope, (this.#agentEventHandlersByScope.get(handlerScope) ?? 0) + 1); const handler = (async (): Promise => { try { // A terminal that carries owner context must win the exact producer claim; @@ -7063,8 +7157,8 @@ export class AgentSession { const pendingAgentEnd = event.type === "agent_end" && !maintenanceCheckpoint && - (this.#pendingAgentEndEmit === event || this.#deferredAgentEndLeases.has(event)) - ? this.#pendingAgentEndEmit + this.#pendingAgentEndEmit === event + ? event : undefined; if (pendingAgentEnd) { this.#deferredAgentEndLeases.set(pendingAgentEnd, eventLease); @@ -7081,6 +7175,11 @@ export class AgentSession { } } this.#agentEventHandlersInFlight = Math.max(0, this.#agentEventHandlersInFlight - 1); + if (handlerScope) { + const count = this.#agentEventHandlersByScope.get(handlerScope); + if (count === 1) this.#agentEventHandlersByScope.delete(handlerScope); + else if (count !== undefined) this.#agentEventHandlersByScope.set(handlerScope, count - 1); + } this.#flushPendingAgentEnd(); agentEndHandled?.resolve(); // Every other in-flight counter republishes settlement when it drops; this @@ -7274,6 +7373,13 @@ export class AgentSession { async #emitSessionEvent(event: AgentSessionEvent, eventLease?: RunResourceProducerLease): Promise { const attemptScope = (event as AgentSessionEvent & { scope?: AttemptScope }).scope; + if (event.type === "agent_failed" && attemptScope && this.#sdkRunTokensByAttemptScope.has(attemptScope)) { + // Retry policy has not selected the logical run's terminal yet. Keep the + // sanitized candidate with its exact attempt, without writing invocation + // failure state that a recovered successor would have to erase. + this.#deferredSdkAttemptFailures.set(attemptScope, event); + return; + } if (event.type === "turn_start") { const delegationHintEnabled = this.settings.get("task.delegationHint.mode") === "hint"; this.#delegationHint.setEnabled(delegationHintEnabled); @@ -7322,14 +7428,15 @@ export class AgentSession { if ( event.type === "agent_end" && sdkTerminal && - this.#pendingAgentEndContinuationHolds.size === 0 && + this.#canPublishAgentEnd(event) && this.#pendingAgentEndEmit === undefined ) { if (eventLease) this.#deferredAgentEndLeases.set(event, eventLease); this.#startAgentEndPublication(event, eventLease); return; } - if (event.type === "agent_end" && (this.#livePromptsInFlight() > 0 || this.#agentEventHandlersInFlight > 0)) { + if (event.type === "agent_end" && !this.#canPublishAgentEnd(event)) { + if (eventLease) this.#deferredAgentEndLeases.set(event, eventLease); this.#pendingAgentEndEmit = event; return; } @@ -7440,6 +7547,10 @@ export class AgentSession { eventLease?: RunResourceProducerLease, ): Promise => { const attemptScope = (event as AgentEvent & { scope?: AttemptScope }).scope; + const agentEndSdkRunToken = + event.type === "agent_end" + ? (attemptScope ? this.#sdkRunTokensByAttemptScope.get(attemptScope) : undefined) ?? this.#activeSdkRunToken + : undefined; // These lifecycle boundaries can be delivered without awaiting this listener. // Revoke streaming-edit cache generations before any admission, spill, or @@ -8242,6 +8353,7 @@ export class AgentSession { transportFailure, messageScope?.scope ?? event.scope, messageScope?.wasClean ?? false, + { event, lease: eventLease }, ); if (didRetry) return; // Retry was initiated, don't proceed to compaction } @@ -8286,7 +8398,7 @@ export class AgentSession { if (await this.#checkGoalCompletion(msg)) { return; } - await this.#checkTodoCompletion(); + await this.#checkTodoCompletion(agentEndSdkRunToken); } } }; @@ -8467,7 +8579,7 @@ export class AgentSession { suppressPredecessorAgentEnd?: boolean; shouldContinue?: () => boolean; onSkip?: ( - reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress" | "terminal_turn", + reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress" | "terminal_turn" | "terminal_pending", ) => void; allowDuringCancelAndSubmit?: boolean; onError?: (error: unknown) => void; @@ -8535,7 +8647,7 @@ export class AgentSession { : undefined); let terminalized = false; const skip = ( - reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress" | "terminal_turn", + reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress" | "terminal_turn" | "terminal_pending", ) => { if (terminalized) return; terminalized = true; @@ -8613,6 +8725,13 @@ export class AgentSession { skip("queue_drained"); return; } + // Recheck after every awaited fence: independently scheduled queue work + // must leave its messages and the genuine SDK terminal owner intact. + // Owning todo/retry continuations do not use continueQueuedOnly. + if (options?.continueQueuedOnly && this.#hasSdkQueuedTerminalBarrier()) { + skip("terminal_pending"); + return; + } // Final synchronous boundary before agent.continue* entry; no await // intervenes between here and method entry. A same-turn continuation // of a terminally aborted turn is denied here, while an independent @@ -14819,7 +14938,7 @@ export class AgentSession { * run that would have polled the queue is gone, so nothing else owns it. */ #scheduleQueuedDelivery(delayMs?: number): void { - if (this.#cancelAndSubmitInProgress) return; + if (this.#cancelAndSubmitInProgress || this.#hasSdkQueuedTerminalBarrier()) return; if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) { this.#queuedDeliveryPendingWhileTransition = true; return; @@ -15000,7 +15119,7 @@ export class AgentSession { // acceptance so its run token is bound to the agent_start. Releasing it // behind still-queued work reproduces the token-less mid-run consumption // hazard, so wait for the queue to drain; the next agent_end retries. - if (this.agent.state.isStreaming || this.agent.hasQueuedMessages()) return false; + if (this.agent.state.isStreaming || this.agent.hasQueuedMessages() || this.#hasSdkQueuedTerminalBarrier()) return false; const message = this.#deferredSdkFollowUps[0]; if (!message) return false; const batch = this.#deferredFollowUpBatches.get(message); @@ -15032,6 +15151,7 @@ export class AgentSession { * Gate for idle-path follow-up auto-continue. See `#queueFollowUp` for rationale. */ #canAutoContinueForFollowUp(): boolean { + if (this.#hasSdkQueuedTerminalBarrier()) return false; if (this.#abortUnwind) return false; if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) return false; if (this.isStreaming) return false; @@ -15068,6 +15188,7 @@ export class AgentSession { * compaction/bash/eval owns the session and defers delivery. */ #canDeliverQueuedMessages(): boolean { + if (this.#hasSdkQueuedTerminalBarrier()) return false; if (this.#abortUnwind) return false; if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) return false; if (this.agent.state.isStreaming) return false; @@ -15086,6 +15207,7 @@ export class AgentSession { * false here because it polls the steering queue itself. */ #canAutoContinueForSteer(): boolean { + if (this.#hasSdkQueuedTerminalBarrier()) return false; if (this.#abortUnwind) return false; if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) return false; if (this.agent.state.isStreaming) return false; @@ -15942,25 +16064,50 @@ export class AgentSession { queuedPromotionFired = true; options?.onQueuedPromoted?.({ startsOwnRun: true }); }; - await this.prompt(text, { - expandPromptTemplates: false, - images, - sdkRunToken: internalOptions?.sdkRunToken, - onPreflightAccepted: () => { - options?.onPreflightAccepted?.(); - fireQueuedPromotion(); - }, - onPreflightAcceptCommit: - options?.onPreflightAcceptCommit || freshAtReservation || promoteAfterAbortUnwind - ? async () => { - if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit(); - else options?.onPreflightAccepted?.(); - assertPreflightStillOpen(); - fireQueuedPromotion(); - } - : undefined, - preflightSignal: options?.preflightSignal, - } as InternalPromptOptions); + const sdkRunToken = internalOptions?.sdkRunToken; + const publicationWaiter = + sdkRunToken === undefined + ? undefined + : { ...Promise.withResolvers(), accepted: false, failed: false, error: undefined as unknown }; + if (sdkRunToken !== undefined && publicationWaiter) { + if (this.#sdkRunPublicationWaiters.has(sdkRunToken)) + throw new Error("An SDK submission already owns this publication token."); + this.#sdkRunPublicationWaiters.set(sdkRunToken, publicationWaiter); + } + try { + await this.prompt(text, { + expandPromptTemplates: false, + images, + sdkRunToken, + onPreflightAccepted: () => { + options?.onPreflightAccepted?.(); + fireQueuedPromotion(); + }, + onPreflightAcceptCommit: + options?.onPreflightAcceptCommit || freshAtReservation || promoteAfterAbortUnwind + ? async () => { + if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit(); + else options?.onPreflightAccepted?.(); + assertPreflightStillOpen(); + fireQueuedPromotion(); + } + : undefined, + preflightSignal: options?.preflightSignal, + } as InternalPromptOptions); + } finally { + // Only the outer own-run submission waits here, after prompt admission + // and its in-flight marker have unwound. Nested continuations must not + // await a terminal that their containing task still owns. + try { + if (publicationWaiter?.accepted) { + await publicationWaiter.promise; + if (publicationWaiter.failed) throw publicationWaiter.error; + } + } finally { + if (sdkRunToken !== undefined && this.#sdkRunPublicationWaiters.get(sdkRunToken) === publicationWaiter) + this.#sdkRunPublicationWaiters.delete(sdkRunToken); + } + } } finally { // A preflight fence or queue admission error can occur after the tracked // state is allocated but before a queue callback binds its exact message. @@ -21445,7 +21592,7 @@ export class AgentSession { /** * Check if agent stopped with incomplete todos and prompt to continue. */ - async #checkTodoCompletion(): Promise { + async #checkTodoCompletion(sdkRunToken: string | undefined): Promise { // Skip todo reminders when the most recent turn was driven by an explicit user force — // the user wanted exactly that tool, not a follow-up nag about incomplete todos. const lastServedLabel = this.#toolChoiceQueue.consumeLastServedLabel(); @@ -21539,7 +21686,7 @@ export class AgentSession { // The reminder continues the current prompt, so the predecessor `agent_end` // must stay held until the continuation turn produces the real terminal. // Publishing it here would settle the caller's prompt mid-reminder. - this.#scheduleAgentContinue({ skipCompactionCheck: true, suppressPredecessorAgentEnd: true }); + this.#scheduleAgentContinue({ skipCompactionCheck: true, suppressPredecessorAgentEnd: true, sdkRunToken }); } /** @@ -24348,6 +24495,7 @@ export class AgentSession { transportFailure?: TransportFailureFacts, scope?: AttemptScope, scopeWasClean = this.#isRetryScopeClean(scope), + terminalOwner?: { event: AgentEvent; lease: RunResourceProducerLease | undefined }, ): Promise { // Capture the SDK owner before the failed attempt retires its active scope. // Retry/continuation terminals must remain attributed to the prompt that @@ -24357,6 +24505,28 @@ export class AgentSession { const retryAbortEpoch = this.#abortAdmissionEpoch; const retryCancelled = () => this.#isDisposed || this.#sessionAdmissionClosing || this.#abortAdmissionEpoch !== retryAbortEpoch; + const acknowledgeBackoffAbort = () => { + if ( + !managedOutcome && + scope && + retrySdkRunToken !== undefined && + terminalOwner?.lease && + terminalOwner.event.type === "agent_end" && + terminalOwner.event.scope === scope && + this.#pendingAgentEndEmit === terminalOwner.event && + this.#sdkRunTokensByAttemptScope.get(scope) === retrySdkRunToken && + this.#deferredSdkAttemptFailures.has(scope) && + this.#abortAdmissionEpoch !== retryAbortEpoch && + !this.#isDisposed && + !this.#sessionAdmissionClosing + ) { + this.#cancelledSdkRecoveryEnds.set(terminalOwner.event, { + scope, + sdkRunToken: retrySdkRunToken, + lease: terminalOwner.lease, + }); + } + }; const controller = this.#defaultFallbackChain(); const managedFallback = controller.chain.entries.length > 1; const retrySettings = this.settings.getGroup("retry"); @@ -24916,6 +25086,7 @@ export class AgentSession { if (this.#retryNowRequested) { // Fall through below so the retry continues immediately. } else { + acknowledgeBackoffAbort(); const attempt = this.#retryAttempt; this.#retryAttempt = 0; await this.#emitSessionEvent({ @@ -24934,6 +25105,7 @@ export class AgentSession { ) { if (this.#retryAbortController !== retryAbortController) return; this.#retryAbortController = undefined; + acknowledgeBackoffAbort(); const attempt = this.#retryAttempt; this.#retryAttempt = 0; await this.#emitSessionEvent({ diff --git a/packages/coding-agent/src/session/internal/managed-session-scope.ts b/packages/coding-agent/src/session/internal/managed-session-scope.ts index eeec948d43c..b561698af35 100644 --- a/packages/coding-agent/src/session/internal/managed-session-scope.ts +++ b/packages/coding-agent/src/session/internal/managed-session-scope.ts @@ -814,8 +814,30 @@ export function resolveManagedScopeForWrite(input: ManagedScopeInput): ManagedSc export function resolveManagedGcScopeForRead(input: ManagedScopeInput): ManagedScopeResolution { const resolved = resolveManagedScope(input); if (resolved.kind === "error") return resolved; - const scope = resolved.scope; + return establishExistingManagedGcScopeAuthority(resolved.scope); +} + +function assertManagedScopeConfiguration(scope: ManagedScope): void { + const configuration = managedScopeConfigurations.get(scope); + if ( + !configuration || + scope.agentDir !== configuration.agentDir || + scope.sessionsRoot !== configuration.sessionsRoot || + scope.canonicalCwd !== configuration.canonicalCwd || + scope.directoryName !== configuration.directoryName || + scope.directoryPath !== configuration.directoryPath || + scope.platform !== configuration.platform || + scope.apiVersion !== 1 || + scope.layoutVersion !== MANAGED_SESSION_LAYOUT_VERSION || + scope.identityVersion !== MANAGED_SESSION_IDENTITY_VERSION + ) + throw new Error("managed_gc_scope_authority_mismatch"); +} + +function establishExistingManagedGcScopeAuthority(scope: ManagedScope): ManagedScopeResolution { try { + assertManagedScopeConfiguration(scope); + assertRetainedManagedDirectoryIdentity(scope); const rootPath = configuredRootPath(scope); if ( path.resolve(scope.agentDir) !== scope.agentDir || @@ -864,7 +886,7 @@ export function resolveManagedGcScopeForRead(input: ManagedScopeInput): ManagedS managedDirectoryIdentities.set(scope, { dev: scopeDirectory.dev, ino: scopeDirectory.ino }); managedDirectoryAuthorities.set(scope, undefined); readManagedGcScopeIdentities.set(scope, { configuredRoot, profile, sessions }); - return resolved; + return { kind: "resolved", scope }; } catch (error) { return { kind: "error", @@ -2305,18 +2327,7 @@ function managedGcTrustedScope(scope: ManagedScope): ManagedGcTrustedScope { const configuration = managedScopeConfigurations.get(scope); if (!root || !identity || !configuration || !managedDirectoryAuthorities.has(scope)) throw new Error("managed_gc_scope_authority_unavailable"); - if ( - scope.agentDir !== configuration.agentDir || - scope.sessionsRoot !== configuration.sessionsRoot || - scope.canonicalCwd !== configuration.canonicalCwd || - scope.directoryName !== configuration.directoryName || - scope.directoryPath !== configuration.directoryPath || - scope.platform !== configuration.platform || - scope.apiVersion !== 1 || - scope.layoutVersion !== MANAGED_SESSION_LAYOUT_VERSION || - scope.identityVersion !== MANAGED_SESSION_IDENTITY_VERSION - ) - throw new Error("managed_gc_scope_authority_mismatch"); + assertManagedScopeConfiguration(scope); const policy: ManagedSessionSecurityPolicy = scope.platform === "win32" ? "windows-existing-verify-first" : "default"; assertManagedDirectoryRoot(root); @@ -5172,29 +5183,63 @@ async function publishCleanupCompleted( !pendingCleanupReceipt(scope, tombstone, target)?.taskArtifactOwnerTranscriptDeleted) ) throw new Error("durability_failed"); + const completedPath = cleanupReceiptPath(tombstone, target, "completed", 1); + const record = { + schemaVersion: 1, + state: "cleanup_completed", + scope: scopeDigest(scope.platform, scope.canonicalCwd), + tombstone, + attempt: 1, + target: { path: target.path, sessionId: target.sessionId, cwd: target.cwd, identity: target.identity }, + ...(ownerReceipt && ownerOutcome + ? { + taskArtifactOwnerDeletionEvidence: ownerReceipt.taskArtifactOwnerDeletionEvidence, + taskArtifactOwnerRetirementOutcome: ownerOutcome, + taskArtifactOwnerRetired: true, + taskArtifactOwnerTranscriptDeleted: true, + } + : {}), + }; + const serialized = `${JSON.stringify(record, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`; try { - await publishManagedTombstone( - cleanupReceiptPath(tombstone, target, "completed", 1), - { - schemaVersion: 1, - state: "cleanup_completed", - scope: scopeDigest(scope.platform, scope.canonicalCwd), - tombstone, - attempt: 1, - target: { path: target.path, sessionId: target.sessionId, cwd: target.cwd, identity: target.identity }, - ...(ownerReceipt && ownerOutcome - ? { - taskArtifactOwnerDeletionEvidence: ownerReceipt.taskArtifactOwnerDeletionEvidence, - taskArtifactOwnerRetirementOutcome: ownerOutcome, - taskArtifactOwnerRetired: true, - taskArtifactOwnerTranscriptDeleted: true, - } - : {}), - }, - lock.assertOwned, - ); + await publishManagedTombstone(completedPath, record, lock.assertOwned); } catch (error) { if ((error as Error).message !== "destination_conflict") throw error; + lock.assertOwned(); + if (await cleanupCompleted(scope, tombstone, target)) return; + const trusted = managedGcTrustedScope(scope); + const stale = captureManagedFileNoFollow(completedPath); + const value: unknown = JSON.parse(stale.bytes.toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("durability_failed"); + const prior = value as Record; + const priorTarget = prior.target; + if (!priorTarget || typeof priorTarget !== "object" || Array.isArray(priorTarget)) + throw new Error("durability_failed"); + const targetRecord = priorTarget as Record; + const priorIdentity = targetRecord.identity; + if (!priorIdentity || typeof priorIdentity !== "object" || Array.isArray(priorIdentity)) + throw new Error("durability_failed"); + const identityRecord = priorIdentity as Record; + if (typeof identityRecord.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(identityRecord.sha256)) + throw new Error("durability_failed"); + const recertified = { + ...prior, + target: { ...targetRecord, identity: { ...identityRecord, sha256: target.identity.sha256 } }, + }; + if (!deepSame(recertified, JSON.parse(serialized))) throw new Error("durability_failed"); + const store = managedGcScopeStore(scope, trusted); + try { + lock.assertOwned(); + store.replaceExpected( + path.relative(scope.directoryPath, completedPath), + new TextEncoder().encode(serialized), + stale, + ); + lock.assertOwned(); + assertRetainedManagedDirectoryIdentity(scope); + } finally { + store.close(); + } } if (!(await cleanupCompleted(scope, tombstone, target))) throw new Error("durability_failed"); } @@ -5949,6 +5994,10 @@ export async function reconcileManagedTombstones( scope: ManagedScope, expectedCandidate?: ManagedCandidate, ): Promise { + if (!managedDirectoryAuthorities.has(scope)) { + const established = establishExistingManagedGcScopeAuthority(scope); + if (established.kind === "error") throw new Error(established.message); + } const directory = path.join(managedInternalDirectory(scope), MANAGED_TOMBSTONES_DIRECTORY); for (const name of fs.readdirSync(directory)) { const tombstone = path.join(directory, name); @@ -6017,12 +6066,14 @@ export async function reconcileManagedTombstones( (!target.taskArtifactOwnerDeletionEvidence || pending?.taskArtifactOwnerTranscriptDeleted === true) ) { - // A conflicting legacy completion record is not fresh completion authority. + // Re-certify fresh completion proof without borrowing a legacy summary for owner effects. if ( !target.taskArtifactOwnerDeletionEvidence && fs.existsSync(cleanupReceiptPath(tombstone, target, "completed", 1)) - ) + ) { + await publishCleanupCompleted(scope, tombstone, target, lock); continue; + } const ownerProgress = await retireManagedGcOwnerAfterArtifacts( scope, target, diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index f9d87b84250..4d5e2c9a810 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -33,7 +33,8 @@ import { import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui"; import { CachedOutputBlock } from "../tui/output-block"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; -import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading"; +import { MAX_IMAGE_INPUT_BYTES } from "../utils/image-limits"; +import { ImageInputTooLargeError, loadImageInput } from "../utils/image-loading"; import { convertFileWithMarkit } from "../utils/markit"; import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree"; import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader"; diff --git a/packages/coding-agent/src/utils/image-limits.ts b/packages/coding-agent/src/utils/image-limits.ts new file mode 100644 index 00000000000..2c9d7714e3a --- /dev/null +++ b/packages/coding-agent/src/utils/image-limits.ts @@ -0,0 +1,7 @@ +// Shared input policy must remain independent of file loading, tools and sessions. +export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024; +export const MAX_PASTED_IMAGE_SOURCE_BYTES = 64 * 1024 * 1024; +export const MAX_PASTED_IMAGE_OUTPUT_BYTES = 64 * 1024 * 1024; +export const MAX_PASTED_IMAGE_DIMENSION = 20_000; +export const MAX_PASTED_IMAGE_PIXELS = 40_000_000; +export const MAX_PASTED_IMAGE_DECODED_BYTES = 160 * 1024 * 1024; diff --git a/packages/coding-agent/src/utils/image-loading.ts b/packages/coding-agent/src/utils/image-loading.ts index e2534e70342..b81f3259857 100644 --- a/packages/coding-agent/src/utils/image-loading.ts +++ b/packages/coding-agent/src/utils/image-loading.ts @@ -2,9 +2,9 @@ import * as fs from "node:fs/promises"; import type { ImageContent } from "@gajae-code/ai/core"; import { formatBytes, readImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@gajae-code/utils"; import { resolveReadPath } from "../tools/path-utils"; +import { MAX_IMAGE_INPUT_BYTES } from "./image-limits"; import { formatDimensionNote, resizeImageBuffer } from "./image-resize"; -export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024; export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES; const SVG_MIME_TYPE = "image/svg+xml"; diff --git a/packages/coding-agent/src/utils/pasted-image-loading.ts b/packages/coding-agent/src/utils/pasted-image-loading.ts index d940a355fda..c878cb4abf4 100644 --- a/packages/coding-agent/src/utils/pasted-image-loading.ts +++ b/packages/coding-agent/src/utils/pasted-image-loading.ts @@ -4,10 +4,17 @@ import * as os from "node:os"; import * as path from "node:path"; import type { ImageContent } from "@gajae-code/ai/core"; import { formatBytes, parseImageMetadata } from "@gajae-code/utils"; +import { + MAX_IMAGE_INPUT_BYTES, + MAX_PASTED_IMAGE_DECODED_BYTES, + MAX_PASTED_IMAGE_DIMENSION, + MAX_PASTED_IMAGE_OUTPUT_BYTES, + MAX_PASTED_IMAGE_PIXELS, + MAX_PASTED_IMAGE_SOURCE_BYTES, +} from "./image-limits"; import { ImageInputTooLargeError, type LoadedImageInput, - MAX_IMAGE_INPUT_BYTES, materializeImageInput, type TransformedImageInput, transformImageInputBytes, @@ -15,12 +22,6 @@ import { import { DEFAULT_IMAGE_RESIZE_MAX_BYTES } from "./image-resize"; import { MAX_PASTED_IMAGE_COUNT } from "./pasted-image-path"; -export const MAX_PASTED_IMAGE_SOURCE_BYTES = 64 * 1024 * 1024; -export const MAX_PASTED_IMAGE_OUTPUT_BYTES = 64 * 1024 * 1024; -export const MAX_PASTED_IMAGE_DIMENSION = 20_000; -export const MAX_PASTED_IMAGE_PIXELS = 40_000_000; -export const MAX_PASTED_IMAGE_DECODED_BYTES = 160 * 1024 * 1024; - export type PastedImageBatchErrorCode = | "too-many" | "unsafe-path" diff --git a/packages/coding-agent/test/acp-prompt-conformance.test.ts b/packages/coding-agent/test/acp-prompt-conformance.test.ts index e4debd6c7a3..385cdbf63f0 100644 --- a/packages/coding-agent/test/acp-prompt-conformance.test.ts +++ b/packages/coding-agent/test/acp-prompt-conformance.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { acpPromptPayload } from "../src/modes/acp/acp-agent"; +import { validateRequiredPromptText } from "../src/sdk/protocol/adapter-validation"; /** * ACP conformance regressions found while smoke-testing GJC against the Paseo @@ -16,22 +17,17 @@ describe("ACP prompt conformance", () => { expect(payload.images).toEqual([{ data: "aGVsbG8=", mimeType: "image/png" }]); }); - test("an oversize prompt frame is refused before it reaches the 256 KiB transport cap", () => { - // The SDK WebSocket server sets max_message_size/max_frame_size to - // REQUEST_FRAME_BYTES (crates/gjc-sdk/src/query.rs) and answers an oversize - // frame by closing the socket, which reaches the client as an opaque - // connection_closed. The prompt must be measured against the same ceiling. - const limit = 256 * 1024; - const oversize = acpPromptPayload([ - { type: "image", mimeType: "image/png", data: "A".repeat(limit + 1_000) }, - ] as never); - const frameBytes = Buffer.byteLength(JSON.stringify({ text: oversize.text, images: oversize.images })); - expect(frameBytes).toBeGreaterThan(limit); - - const withinLimit = acpPromptPayload([ - { type: "image", mimeType: "image/png", data: "A".repeat(1_000) }, - ] as never); - const smallBytes = Buffer.byteLength(JSON.stringify({ text: withinLimit.text, images: withinLimit.images })); - expect(smallBytes).toBeLessThan(limit); + test("image-only prompts retain the image and accept staged IDs only on turn.prompt", () => { + const payload = acpPromptPayload([{ type: "image", mimeType: "image/png", data: "aGVsbG8=" }] as never); + expect(payload).toEqual({ text: "", images: [{ data: "aGVsbG8=", mimeType: "image/png" }] }); + expect( + validateRequiredPromptText("turn.prompt", { text: payload.text, stagedImages: [{ id: "host-owned" }] }), + ).toBeUndefined(); + expect(validateRequiredPromptText("turn.prompt", { text: "", stagedImages: [{ id: "" }] })).toMatchObject({ + code: "invalid_input", + }); + expect( + validateRequiredPromptText("turn.steer", { text: "", stagedImages: [{ id: "host-owned" }] }), + ).toMatchObject({ code: "invalid_input" }); }); }); diff --git a/packages/coding-agent/test/acp-prompt-watchdog.test.ts b/packages/coding-agent/test/acp-prompt-watchdog.test.ts index d11a42ff211..13375f7e684 100644 --- a/packages/coding-agent/test/acp-prompt-watchdog.test.ts +++ b/packages/coding-agent/test/acp-prompt-watchdog.test.ts @@ -5,8 +5,10 @@ import { getProviderFirstEventTimeoutFallbackMs } from "@gajae-code/ai/utils/idl import { logger, TempDir } from "@gajae-code/utils"; import packageJson from "../package.json" with { type: "json" }; import { AcpAgent } from "../src/modes/acp/acp-agent"; -import { AcpSdkAdapter } from "../src/sdk/acp/adapter"; +import { type AcpReconnectFailedHandler, AcpSdkAdapter } from "../src/sdk/acp/adapter"; import { writeBrokerDiscovery } from "../src/sdk/broker/discovery"; +import { SdkClientError } from "../src/sdk/client"; +import { PromptImageUploadStore } from "../src/sdk/host/prompt-image-upload"; import { ACP_PROMPT_INACTIVITY_TIMEOUT_MS, ACP_PROMPT_INFERENCE_TIMEOUT_MS, @@ -83,6 +85,9 @@ type Fixture = { /** Correlation the fixture host acknowledged for the turn currently in flight. */ correlation(): { commandId: string; turnId: string }; promptDeliveryCount(): number; + imageUploadCount(): number; + liveImageUploadCount(): number; + abortCount(): number; /** Sends one raw frame down the session socket, correlation included or omitted verbatim. */ send(frame: Record): void; sendAssistantText(text: string): void; @@ -161,6 +166,12 @@ type FixtureOptions = { cancelSettlementGraceMs?: number; preflightCancelAcknowledgement?: boolean; deferCancelAcknowledgement?: boolean; + noActiveTurnAbort?: boolean; + imageEchoGate?: { started: () => void; release: Promise }; + imageProgressMs?: number; + allowLiveSessionRecovery?: boolean; + recoveryListGate?: { started: () => void; release: Promise }; + imageControlGate?: { operation: string; started: () => void; release: Promise; completed?: () => void }; }; async function createFixture(options: FixtureOptions = {}): Promise { @@ -172,7 +183,12 @@ async function createFixture(options: FixtureOptions = {}): Promise { const updates: SessionNotification[] = []; const clock = new VirtualClock(); const abort = new AbortController(); + const imageUploads = new PromptImageUploadStore(() => !abort.signal.aborted); + const liveImageIds = new Set(); + let imageSocket: TestSocket | undefined; let turnCount = 0; + let imageUploadCount = 0; + let abortCount = 0; let commandId = ""; let turnId = ""; let promptSocket: TestSocket | undefined; @@ -318,6 +334,12 @@ async function createFixture(options: FixtureOptions = {}): Promise { open(socket) { socket.send(JSON.stringify({ type: "hello", connectionId: "acp-prompt-watchdog" })); }, + close(socket) { + if (socket === imageSocket) { + imageUploads.disconnect("acp-prompt-watchdog"); + liveImageIds.clear(); + } + }, message(socket, raw) { const frame = JSON.parse(String(raw)) as Record; if (frame.type === "register_provider") { @@ -326,7 +348,52 @@ async function createFixture(options: FixtureOptions = {}): Promise { ); return; } + if (options.allowLiveSessionRecovery && frame.type === "event_replay") { + // The held echo is pre-dispatch; this fixture has no sequenced journal events. + socket.send( + JSON.stringify({ + type: "event_replay_result", + id: frame.id, + ok: true, + generation: authority.endpointGeneration, + lastSeq: 0, + events: [], + }), + ); + return; + } if (frame.type === "broker_request") { + if (options.allowLiveSessionRecovery && frame.operation === "session.list") { + void (async () => { + if (options.recoveryListGate) { + options.recoveryListGate.started(); + await options.recoveryListGate.release; + } + // Attachment retirement does not stop this still-live published fixture host. + socket.send( + JSON.stringify({ + type: "broker_response", + id: frame.id, + ok: true, + result: { + sessions: [ + { + ...authority, + sessionId, + locator: { cwd, worktreeRoot: null, stateRoot: path.join(cwd, ".gjc", "state") }, + live: true, + }, + ], + }, + }), + ); + })(); + return; + } + if (options.allowLiveSessionRecovery && frame.operation === "session.get_endpoint") { + socket.send(JSON.stringify({ type: "broker_response", id: frame.id, ok: true, result: authority })); + return; + } if (frame.operation !== "session.create") { socket.send(JSON.stringify({ type: "broker_response", id: frame.id, ok: true, result: {} })); return; @@ -354,7 +421,67 @@ async function createFixture(options: FixtureOptions = {}): Promise { return; } if (frame.type !== "control_request") return; + if (typeof frame.operation === "string" && frame.operation.startsWith("turn.image.")) { + imageUploadCount++; + imageSocket = socket; + void (async () => { + const gate = options.imageControlGate; + try { + if (gate && frame.operation === gate.operation) { + gate.started(); + await gate.release; + } + if (frame.operation !== "turn.image.discard") clock.advance(options.imageProgressMs ?? 0); + const owner = "acp-prompt-watchdog"; + const result = + frame.operation === "turn.image.begin" + ? imageUploads.begin(owner, frame.input) + : frame.operation === "turn.image.append" + ? imageUploads.append(owner, frame.input) + : frame.operation === "turn.image.finish" + ? await imageUploads.finish(owner, frame.input) + : imageUploads.discard(owner, frame.input); + if (frame.operation === "turn.image.begin") liveImageIds.add((result as { id: string }).id); + if (frame.operation === "turn.image.discard") + liveImageIds.delete((frame.input as { id: string }).id); + socket.send(JSON.stringify({ type: "control_response", id: frame.id, ok: true, result })); + } catch (error) { + const failure = error as Error & { code?: string }; + socket.send( + JSON.stringify({ + type: "control_response", + id: frame.id, + ok: false, + error: { code: failure.code ?? "internal", message: failure.message }, + }), + ); + } finally { + if (gate && frame.operation === gate.operation) gate.completed?.(); + } + })(); + return; + } + if (frame.operation === "turn.abort") abortCount++; if (frame.operation === "turn.prompt") { + const stagedImages = (frame.input as { stagedImages?: Array<{ id: string }> })?.stagedImages; + if (stagedImages) { + try { + const reservation = imageUploads.redeem("acp-prompt-watchdog", stagedImages); + for (const { id } of stagedImages) liveImageIds.delete(id); + reservation.release(); + } catch (error) { + const failure = error as Error & { code?: string }; + socket.send( + JSON.stringify({ + type: "control_response", + id: frame.id, + ok: false, + error: { code: failure.code ?? "internal", message: failure.message }, + }), + ); + return; + } + } promptSocket = socket; turnCount += 1; commandId = `watchdog-command-${turnCount}`; @@ -368,6 +495,13 @@ async function createFixture(options: FixtureOptions = {}): Promise { : frame.operation === "turn.abort" ? (() => { const scope = (frame.input as { scope?: string })?.scope === "owned" ? "owned" : "turn"; + if (options.noActiveTurnAbort) + return { + ok: true, + selection: scope, + turn: "no_active_turn", + terminal: "terminal_no_effect", + }; return { ok: true, selection: scope, @@ -434,7 +568,18 @@ async function createFixture(options: FixtureOptions = {}): Promise { }); const agent = new AcpAgent( { - sessionUpdate: async (update: SessionNotification) => updates.push(update), + sessionUpdate: async (update: SessionNotification) => { + const chunk = update.update as { sessionUpdate?: string; content?: { type?: string } }; + if ( + chunk.sessionUpdate === "user_message_chunk" && + chunk.content?.type === "image" && + options.imageEchoGate + ) { + options.imageEchoGate.started(); + await options.imageEchoGate.release; + } + updates.push(update); + }, signal: abort.signal, closed: Promise.withResolvers().promise, } as unknown as AgentSideConnection, @@ -456,6 +601,9 @@ async function createFixture(options: FixtureOptions = {}): Promise { clock, correlation: () => ({ commandId, turnId }), promptDeliveryCount: () => turnCount, + imageUploadCount: () => imageUploadCount, + liveImageUploadCount: () => liveImageIds.size, + abortCount: () => abortCount, send, sendAssistantText, sendAssistantToolCall, @@ -468,6 +616,7 @@ async function createFixture(options: FixtureOptions = {}): Promise { acknowledgePrompt, dispose: () => { abort.abort(); + imageUploads.close(); server.stop(true); tempDir.removeSync(); }, @@ -650,6 +799,457 @@ test("a silent completed todo_write argument stream rejects the ACP client withi } }); +test("validated image staging progress renews only the current undispatched watchdog", async () => { + const fixture = await createFixture({ imageProgressMs: ACP_PROMPT_INACTIVITY_TIMEOUT_MS - 1 }); + try { + const image = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + const pending = fixture.agent.prompt({ + sessionId: fixture.sessionId, + prompt: [{ type: "image", mimeType: "image/png", data: image.toString("base64") }], + } as PromptRequest); + await waitFor(() => fixture.promptDeliveryCount() === 1, "slow but progressing image dispatch"); + expect(fixture.clock.now()).toBeGreaterThan(2 * ACP_PROMPT_INACTIVITY_TIMEOUT_MS); + expect(fixture.liveImageUploadCount()).toBe(0); + expect(fixture.updates.filter(update => update.update.sessionUpdate === "user_message_chunk")).toHaveLength(1); + fixture.sendStopped("end_turn"); + expect(await bounded(pending, "slow staged completion")).toEqual({ stopReason: "end_turn" }); + } finally { + fixture.dispose(); + } +}); + +for (const operation of ["turn.image.begin", "turn.image.append", "turn.image.finish"]) { + test(`cancelling held ${operation} is local and cannot publish a late image echo`, async () => { + const entered = Promise.withResolvers(); + const gate = Promise.withResolvers(); + const completed = Promise.withResolvers(); + const fixture = await createFixture({ + imageControlGate: { operation, started: entered.resolve, release: gate.promise, completed: completed.resolve }, + }); + try { + const image = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + const pending = fixture.agent.prompt({ + sessionId: fixture.sessionId, + prompt: [ + { type: "text", text: "image attempt" }, + { type: "image", mimeType: "image/png", data: image.toString("base64") }, + ], + } as PromptRequest); + await bounded(entered.promise, "held image operation"); + await fixture.agent.cancel({ sessionId: fixture.sessionId }); + expect(await bounded(pending, "cancelled staging")).toEqual({ stopReason: "cancelled" }); + expect(fixture.abortCount()).toBe(0); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(fixture.updates.filter(update => update.update.sessionUpdate === "user_message_chunk")).toHaveLength(0); + gate.resolve(); + await bounded(completed.promise, "held image response completion"); + await waitFor(() => fixture.liveImageUploadCount() === 0, "cancelled upload cleanup"); + await Bun.sleep(20); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(fixture.liveImageUploadCount()).toBe(0); + expect(fixture.updates.filter(update => update.update.sessionUpdate === "user_message_chunk")).toHaveLength(0); + } finally { + gate.resolve(); + fixture.dispose(); + } + }); +} + +test("a stalled image echo cannot hold a settled prompt or dispatch after late publication", async () => { + const echoStarted = Promise.withResolvers(); + const echoGate = Promise.withResolvers(); + const fixture = await createFixture({ imageEchoGate: { started: echoStarted.resolve, release: echoGate.promise } }); + try { + const image = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + expect(image.length).toBeGreaterThan(256 * 1024); + const pending = fixture.agent.prompt({ + sessionId: fixture.sessionId, + prompt: [{ type: "image", mimeType: "image/png", data: image.toString("base64") }], + } as PromptRequest); + let settlements = 0; + void pending.then( + () => settlements++, + () => settlements++, + ); + await bounded(echoStarted.promise, "image echo publication"); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(fixture.imageUploadCount()).toBeGreaterThan(0); + expect(fixture.liveImageUploadCount()).toBe(1); + expect(fixture.clock.pending).toBe(1); + + fixture.clock.advance(ACP_PROMPT_INACTIVITY_TIMEOUT_MS + 1); + await expect(bounded(pending, "stalled image echo watchdog")).rejects.toMatchObject({ + code: "prompt_abandoned", + }); + expect(settlements).toBe(1); + expect(fixture.promptDeliveryCount()).toBe(0); + await waitFor(() => fixture.liveImageUploadCount() === 0, "abandoned echo upload retirement"); + await expect(prompt(fixture, "after abandoned echo")).rejects.toMatchObject({ code: "not_found" }); + + echoGate.resolve(); + await Bun.sleep(20); + expect(settlements).toBe(1); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(fixture.liveImageUploadCount()).toBe(0); + } finally { + echoGate.resolve(); + fixture.dispose(); + } +}); + +test("cancelling before image echo completes settles locally without aborting a host turn", async () => { + const echoStarted = Promise.withResolvers(); + const echoGate = Promise.withResolvers(); + const fixture = await createFixture({ imageEchoGate: { started: echoStarted.resolve, release: echoGate.promise } }); + try { + const image = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + const pending = fixture.agent.prompt({ + sessionId: fixture.sessionId, + prompt: [{ type: "image", mimeType: "image/png", data: image.toString("base64") }], + } as PromptRequest); + await bounded(echoStarted.promise, "image echo publication before cancel"); + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "local pre-dispatch cancel"); + expect(await bounded(pending, "cancelled image echo")).toEqual({ stopReason: "cancelled" }); + expect(fixture.abortCount()).toBe(0); + expect(fixture.promptDeliveryCount()).toBe(0); + await waitFor(() => fixture.liveImageUploadCount() === 0, "cancelled echo upload retirement"); + expect(fixture.imageUploadCount()).toBeGreaterThan(0); + expect(fixture.clock.pending).toBe(1); + const followUp = prompt(fixture, "after cancelled image echo"); + await Bun.sleep(20); + expect(fixture.promptDeliveryCount()).toBe(0); + echoGate.resolve(); + await waitFor(() => fixture.promptDeliveryCount() === 1, "follow-up after delayed image echo"); + expect(fixture.liveImageUploadCount()).toBe(0); + const userEchoes = fixture.updates.filter(update => update.update.sessionUpdate === "user_message_chunk"); + expect(userEchoes.map(update => (update.update as { content: { type: string } }).content.type)).toEqual([ + "image", + "text", + ]); + fixture.sendStopped("end_turn"); + expect(await bounded(followUp, "follow-up after cancelled image echo")).toEqual({ stopReason: "end_turn" }); + } finally { + echoGate.resolve(); + fixture.dispose(); + } +}); + +test.each([ + "drain", + "cancel", + "deadline", + "reject", + "gap-deadline", +] as const)("cancelled image echo retains its %s disposition across same-ID replacement", async mode => { + const handlers: AcpReconnectFailedHandler[] = []; + const originalSubscribe = AcpSdkAdapter.prototype.onReconnectFailed; + const subscription = vi.spyOn(AcpSdkAdapter.prototype, "onReconnectFailed").mockImplementation(function ( + this: AcpSdkAdapter, + handler: AcpReconnectFailedHandler, + ): () => void { + handlers.push(handler); + return originalSubscribe.call(this, handler); + }); + const echoStarted = Promise.withResolvers(); + const echoGate = Promise.withResolvers(); + const recoveryStarted = Promise.withResolvers(); + const recoveryGate = Promise.withResolvers(); + let fixture: Fixture | undefined; + try { + fixture = await createFixture({ + allowLiveSessionRecovery: true, + imageEchoGate: { started: echoStarted.resolve, release: echoGate.promise }, + ...(mode === "gap-deadline" + ? { recoveryListGate: { started: recoveryStarted.resolve, release: recoveryGate.promise } } + : {}), + }); + const image = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + const predecessor = fixture.agent.prompt({ + sessionId: fixture.sessionId, + prompt: [{ type: "image", mimeType: "image/png", data: image.toString("base64") }], + } as PromptRequest); + await bounded(echoStarted.promise, "predecessor image publication"); + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "pre-dispatch cancellation"); + expect(await bounded(predecessor, "predecessor cancellation")).toEqual({ stopReason: "cancelled" }); + const installed = handlers.length; + const failure = handlers.at(-1); + if (!failure) throw new Error("Expected actual reconnect failure subscription"); + failure(new SdkClientError("reconnect_exhausted", "fixture transport recovery")); + if (mode === "gap-deadline") { + await bounded(recoveryStarted.promise, "recordless recovery gap"); + fixture.clock.advance(ACP_PROMPT_INACTIVITY_TIMEOUT_MS); + recoveryGate.resolve(); + } + await waitFor(() => handlers.length > installed, "same-ID replacement subscription"); + const successor = prompt(fixture, "after replacement"); + void successor.catch(() => undefined); + await Bun.sleep(20); + expect(fixture.abortCount()).toBe(0); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(fixture.updates.filter(update => update.update.sessionUpdate === "user_message_chunk")).toHaveLength(0); + if (mode === "drain") { + echoGate.resolve(); + await waitFor(() => fixture?.promptDeliveryCount() === 1, "ordered successor dispatch"); + expect( + fixture.updates + .filter(update => update.update.sessionUpdate === "user_message_chunk") + .map(update => (update.update as { content: { type: string } }).content.type), + ).toEqual(["image", "text"]); + fixture.sendStopped("end_turn"); + expect(await bounded(successor, "successor terminal")).toEqual({ stopReason: "end_turn" }); + } else if (mode === "cancel") { + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "cancel pending successor"); + expect(await bounded(successor, "pending successor cancellation")).toEqual({ stopReason: "cancelled" }); + echoGate.resolve(); + await Bun.sleep(20); + expect(fixture.promptDeliveryCount()).toBe(0); + } else { + if (mode === "deadline") fixture.clock.advance(ACP_PROMPT_INACTIVITY_TIMEOUT_MS); + if (mode === "reject") echoGate.reject(new Error("fixture image publication rejected")); + await expect(bounded(successor, "failed replacement publication")).rejects.toMatchObject({ + code: "connection_closed", + }); + if (mode === "gap-deadline") { + echoGate.resolve(); + await Bun.sleep(20); + await expect(prompt(fixture, "after late failed echo completion")).rejects.toMatchObject({ + code: "connection_closed", + }); + } else { + await expect(prompt(fixture, "after publication failure")).rejects.toMatchObject({ code: "not_found" }); + } + expect(fixture.promptDeliveryCount()).toBe(0); + } + expect(fixture.abortCount()).toBe(0); + } finally { + echoGate.resolve(); + recoveryGate.resolve(); + fixture?.dispose(); + subscription.mockRestore(); + } +}); + +test("a successor behind an already-settled cancelled image echo is cancellable before the echo clears", async () => { + const echoStarted = Promise.withResolvers(); + const echoGate = Promise.withResolvers(); + const fixture = await createFixture({ + noActiveTurnAbort: true, + imageEchoGate: { started: echoStarted.resolve, release: echoGate.promise }, + }); + try { + const image = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + const predecessor = fixture.agent.prompt({ + sessionId: fixture.sessionId, + prompt: [{ type: "image", mimeType: "image/png", data: image.toString("base64") }], + } as PromptRequest); + await bounded(echoStarted.promise, "cancelled predecessor image echo"); + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "cancel predecessor before dispatch"); + expect(await bounded(predecessor, "settled predecessor cancellation")).toEqual({ stopReason: "cancelled" }); + await waitFor(() => fixture.liveImageUploadCount() === 0, "predecessor staged upload retirement"); + const uploadRequests = fixture.imageUploadCount(); + + const successor = prompt(fixture, "successor cancelled while predecessor echo is stuck"); + const cancellation = fixture.agent.cancel({ sessionId: fixture.sessionId }); + await bounded(cancellation, "cancel pending successor admission"); + expect(await bounded(successor, "cancelled pending successor")).toEqual({ stopReason: "cancelled" }); + expect(fixture.abortCount()).toBe(0); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(fixture.imageUploadCount()).toBe(uploadRequests); + expect(fixture.liveImageUploadCount()).toBe(0); + + echoGate.resolve(); + await Bun.sleep(20); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(fixture.imageUploadCount()).toBe(uploadRequests); + expect(fixture.liveImageUploadCount()).toBe(0); + } finally { + echoGate.resolve(); + fixture.dispose(); + } +}); + +test("a successor cancellation is independent while its echoed-image predecessor remains active", async () => { + const fixture = await createFixture(); + try { + const predecessor = fixture.agent.prompt({ + sessionId: fixture.sessionId, + prompt: [{ type: "image", mimeType: "image/png", data: Buffer.from([1, 2, 3]).toString("base64") }], + } as PromptRequest); + await waitFor(() => fixture.promptDeliveryCount() === 1, "echoed image predecessor delivery"); + await waitFor(() => workingUpdates(fixture.updates) > 0, "echoed image predecessor start"); + expect( + fixture.updates.filter( + update => + update.update.sessionUpdate === "user_message_chunk" && + (update.update as { content: { type: string } }).content.type === "image", + ), + ).toHaveLength(1); + + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "active image predecessor cancellation"); + const successor = prompt(fixture, "successor cancelled during predecessor shutdown"); + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "cancel active predecessor successor"); + expect(await bounded(successor, "cancelled active-predecessor successor")).toEqual({ stopReason: "cancelled" }); + expect(fixture.promptDeliveryCount()).toBe(1); + fixture.sendStopped("cancelled"); + expect(await bounded(predecessor, "cancelled echoed-image predecessor")).toEqual({ stopReason: "cancelled" }); + } finally { + fixture.dispose(); + } +}); + +test("a reentrant cancel during a throwing prompt socket send never aborts unrelated host work", async () => { + const fixture = await createFixture(); + const send = WebSocket.prototype.send; + let cancelTask: Promise | undefined; + let intercepted = false; + try { + WebSocket.prototype.send = function (this: WebSocket, data: string | ArrayBufferLike | Blob | ArrayBufferView) { + if (typeof data === "string" && JSON.parse(data).operation === "turn.prompt") { + intercepted = true; + cancelTask = fixture.agent.cancel({ sessionId: fixture.sessionId }); + throw new Error("simulated synchronous send failure"); + } + send.call(this, data as string); + }; + const pending = prompt(fixture, "unsent prompt"); + expect(await bounded(pending, "cancelled unsent prompt")).toEqual({ stopReason: "cancelled" }); + await bounded(cancelTask ?? Promise.reject(new Error("Cancel never entered socket send")), "reentrant cancel"); + expect(intercepted).toBe(true); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(fixture.abortCount()).toBe(0); + } finally { + WebSocket.prototype.send = send; + fixture.dispose(); + } +}); + +test("Router pre-send cancellation still settles locally without emitting abort or prompt", async () => { + const fixture = await createFixture(); + const originalPrompt = AcpSdkAdapter.prototype.prompt; + let cancelTask: Promise | undefined; + try { + AcpSdkAdapter.prototype.prompt = function (params, beforeDispatch, onDispatch) { + return originalPrompt.call( + this, + params, + context => { + cancelTask = fixture.agent.cancel({ sessionId: fixture.sessionId }); + beforeDispatch?.(context); + }, + onDispatch, + ); + }; + const pending = prompt(fixture, "cancel at Router pre-send boundary"); + expect(await bounded(pending, "Router pre-send cancelled prompt")).toEqual({ stopReason: "cancelled" }); + await bounded(cancelTask ?? Promise.reject(new Error("Router pre-send hook was not entered")), "pre-send cancel"); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(fixture.abortCount()).toBe(0); + } finally { + AcpSdkAdapter.prototype.prompt = originalPrompt; + fixture.dispose(); + } +}); + +test("a reentrant cancel during a successful prompt socket send waits for dispatch before aborting", async () => { + const fixture = await createFixture(); + const send = WebSocket.prototype.send; + let cancelTask: Promise | undefined; + try { + WebSocket.prototype.send = function (this: WebSocket, data: string | ArrayBufferLike | Blob | ArrayBufferView) { + if (typeof data === "string" && JSON.parse(data).operation === "turn.prompt") { + cancelTask = fixture.agent.cancel({ sessionId: fixture.sessionId }); + } + send.call(this, data as string); + }; + const pending = prompt(fixture, "sent prompt"); + await waitFor(() => fixture.abortCount() === 1, "abort after successful send"); + await bounded(cancelTask ?? Promise.reject(new Error("Cancel never entered socket send")), "reentrant cancel"); + expect(fixture.promptDeliveryCount()).toBe(1); + fixture.sendStopped("cancelled"); + expect(await bounded(pending, "cancelled sent prompt")).toEqual({ stopReason: "cancelled" }); + } finally { + WebSocket.prototype.send = send; + fixture.dispose(); + } +}); + +test("a cancelled image echo that never completes bounds the waiting successor and tears down the session", async () => { + const echoStarted = Promise.withResolvers(); + const echoGate = Promise.withResolvers(); + const fixture = await createFixture({ imageEchoGate: { started: echoStarted.resolve, release: echoGate.promise } }); + try { + const image = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + const cancelled = fixture.agent.prompt({ + sessionId: fixture.sessionId, + prompt: [{ type: "image", mimeType: "image/png", data: image.toString("base64") }], + } as PromptRequest); + await bounded(echoStarted.promise, "stalled image echo before cancel"); + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "cancel stalled image echo"); + expect(await bounded(cancelled, "cancelled stalled image echo")).toEqual({ stopReason: "cancelled" }); + const successor = prompt(fixture, "after permanently stalled echo"); + await Bun.sleep(0); + expect(fixture.clock.pending).toBe(1); + fixture.clock.advance(ACP_PROMPT_INACTIVITY_TIMEOUT_MS + 1); + await expect(bounded(successor, "bounded successor after stalled echo")).rejects.toMatchObject({ + code: "connection_closed", + }); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(fixture.abortCount()).toBe(0); + echoGate.resolve(); + await Bun.sleep(0); + expect(fixture.promptDeliveryCount()).toBe(0); + await expect(prompt(fixture, "after echo teardown")).rejects.toMatchObject({ code: "not_found" }); + } finally { + echoGate.resolve(); + fixture.dispose(); + } +}); + +test("a rejected cancelled image echo tears down the session and does not strand a successor", async () => { + const echoStarted = Promise.withResolvers(); + const echoGate = Promise.withResolvers(); + const fixture = await createFixture({ imageEchoGate: { started: echoStarted.resolve, release: echoGate.promise } }); + try { + const image = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + const cancelled = fixture.agent.prompt({ + sessionId: fixture.sessionId, + prompt: [{ type: "image", mimeType: "image/png", data: image.toString("base64") }], + } as PromptRequest); + await bounded(echoStarted.promise, "image echo before rejected publication"); + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "cancel before echo rejection"); + expect(await bounded(cancelled, "cancelled rejected echo")).toEqual({ stopReason: "cancelled" }); + const successor = prompt(fixture, "after rejected echo"); + echoGate.reject(new Error("client publication rejected")); + // Publication rejects through the existing session frame-processing failure path. + await expect(bounded(successor, "successor after echo rejection")).rejects.toMatchObject({ + code: "frame_processing_failed", + }); + await waitFor(() => fixture.clock.pending === 0, "rejected cancelled echo timer cleanup"); + expect(fixture.clock.pending).toBe(0); + expect(fixture.promptDeliveryCount()).toBe(0); + await expect(prompt(fixture, "after rejected echo teardown")).rejects.toMatchObject({ code: "not_found" }); + } finally { + echoGate.resolve(); + fixture.dispose(); + } +}); + test("a foreign failed terminal cannot clear host busy before watchdog rejection", async () => { const fixture = await createFixture(); try { diff --git a/packages/coding-agent/test/acp-session-reconnect.test.ts b/packages/coding-agent/test/acp-session-reconnect.test.ts index 914be1f4ac1..eb2d95263b3 100644 --- a/packages/coding-agent/test/acp-session-reconnect.test.ts +++ b/packages/coding-agent/test/acp-session-reconnect.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { ACP_SESSION_RECONNECT, AcpSdkAdapter } from "../src/sdk/acp"; import { HEARTBEAT_TTL_MS } from "../src/sdk/bus/daemon-paths"; -import { SdkClientError } from "../src/sdk/client"; +import { SdkClientError, type SdkDispatchContext } from "../src/sdk/client"; import type { SessionAttachment, SessionRouter } from "../src/sdk/router"; import { expectedBackoffs } from "./helpers/fake-sdk-transport"; @@ -24,6 +24,7 @@ test("AcpSdkAdapter requires an explicit Broker client or SessionRouter", async for (const kind of ["prompt", "skill"] as const) { test(`ACP ${kind} uncertainty retains clientRef for a session-bound read without replay`, async () => { const requests: Record[] = []; + let observedDispatch: SdkDispatchContext | undefined; const attachment: SessionAttachment = { sessionId: "recovery-session", connectionId: "original-connection", @@ -38,23 +39,42 @@ for (const kind of ["prompt", "skill"] as const) { frame: Record, generation: number, owner: SessionAttachment, - options?: { timeoutMs: number }, + options?: Parameters[4], ) => { expect(sessionId).toBe(attachment.sessionId); expect(generation).toBe(7); expect(owner).toBe(attachment); - expect(options).toBeUndefined(); requests.push(frame); - if (frame.type === "control_request") throw new SdkClientError("uncertain_after_send", "response lost"); + if (frame.type === "control_request") { + options?.onDispatch?.({ + frame: { ...frame, type: "control_request", id: "original-mutation-request" }, + connectionId: attachment.connectionId, + generation, + }); + throw new SdkClientError("uncertain_after_send", "response lost"); + } return { ok: true, result: { kind, clientRef: "owned-ref", status: "unknown" } }; }, } as unknown as SessionRouter; const adapter = new AcpSdkAdapter({ router, attachment }); const mutation = kind === "prompt" - ? adapter.prompt({ text: "hello", clientRef: "owned-ref" }) + ? adapter.prompt({ text: "hello", clientRef: "owned-ref" }, undefined, context => { + observedDispatch = context; + }) : adapter.control("skill.invoke", { name: "review", args: "hello", clientRef: "owned-ref" }); await expect(mutation).rejects.toMatchObject({ code: "uncertain_after_send" }); + if (kind === "prompt") { + expect(observedDispatch).toMatchObject({ + connectionId: attachment.connectionId, + generation: attachment.generation, + frame: { + id: "original-mutation-request", + operation: "turn.prompt", + input: { clientRef: "owned-ref" }, + }, + }); + } expect(await adapter.query("turn.result", { kind, clientRef: "owned-ref" })).toEqual({ kind, clientRef: "owned-ref", diff --git a/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts b/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts index a51ac9d9f90..8111f162029 100644 --- a/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts +++ b/packages/coding-agent/test/acp/acp-cancel-settlement.test.ts @@ -723,9 +723,13 @@ test("a reconnect before a failed abort does not leave the prompt cancelled", as } }); -test("a no_active_turn abort during provider preflight durably cancels without dispatching", async () => { +test("local cancel during provider preflight settles without dispatch or remote abort", async () => { + let abortCalls = 0; const fixture = await createFixture({ - abortAcknowledgement: { turn: "no_active_turn", terminal: "terminal_no_effect" }, + abortAcknowledgement: () => { + abortCalls++; + return { turn: "no_active_turn", terminal: "terminal_no_effect" }; + }, }); const providerPreflight = Promise.withResolvers(); const releaseProviderPreflight = Promise.withResolvers(); @@ -736,12 +740,12 @@ test("a no_active_turn abort during provider preflight durably cancels without d try { const pending = prompt(fixture, "cancel during provider preflight"); await bounded(providerPreflight.promise, "provider preflight"); - await expect(fixture.agent.cancel({ sessionId: fixture.sessionId })).rejects.toMatchObject({ - code: "abort_unacknowledged", - }); + await fixture.agent.cancel({ sessionId: fixture.sessionId }); + expect(abortCalls).toBe(0); releaseProviderPreflight.resolve(); expect(await bounded(pending, "pre-admission cancellation")).toEqual({ stopReason: "cancelled" }); expect(fixture.promptDeliveryCount()).toBe(0); + expect(abortCalls).toBe(0); } finally { releaseProviderPreflight.resolve(); ensureProviders.mockRestore(); diff --git a/packages/coding-agent/test/agent-session-promotion-identity.test.ts b/packages/coding-agent/test/agent-session-promotion-identity.test.ts index 9948e0afdee..3edad5b914c 100644 --- a/packages/coding-agent/test/agent-session-promotion-identity.test.ts +++ b/packages/coding-agent/test/agent-session-promotion-identity.test.ts @@ -639,6 +639,158 @@ describe("queued promotion run identity (#4668)", () => { expect(session.pendingMessageCounts.followUp).toBe(0); }); + for (const disposition of ["resume", "remove"] as const) { + it(`parks previously scheduled ordinary queue work released from startup during SDK publication (${disposition})`, async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled Anthropic test model to exist"); + const mock = createMockModel({ responses: [{ content: ["SDK answer"] }, { content: ["ordinary answer"] }] }); + const agent = new Agent({ + getApiKey: provider => `${provider}-test-key`, + initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: mock.stream, + }); + const startupFence = Promise.withResolvers(); + const publisherHeld = Promise.withResolvers(); + const releasePublisher = Promise.withResolvers(); + const rawEndObserved = Promise.withResolvers(); + const ordinaryStarted = Promise.withResolvers(); + let queueAdmission: Promise | undefined; + let rawObservation: + | { + rawStreaming: boolean; + sessionStreaming: boolean; + generation?: number; + initialQueueSize: number; + admittedQueueSize: number; + } + | undefined; + let publisherGeneration: number | undefined; + let publisherFinished = false; + const ordinaryText = "ordinary scheduled before SDK terminal barrier"; + const observations: { + kind: "terminal" | "ordinary"; + generation?: number; + publisherFinished?: boolean; + queued?: number; + }[] = []; + // Register before AgentSession, exactly as the existing genuine unwind fixture. + const unsubscribeRaw = agent.subscribe(event => { + if (event.type === "agent_end" && queueAdmission === undefined) { + const current = session!; + const initialQueueSize = agent.snapshotFollowUp().length; + const rawStreaming = agent.state.isStreaming; + const sessionStreaming = current.isStreaming; + current.extendStartupTurnBarrier(startupFence.promise); + queueAdmission = current.followUp(ordinaryText); + rawObservation = { + rawStreaming, + sessionStreaming, + generation: event.scope?.generation, + initialQueueSize, + admittedQueueSize: agent.snapshotFollowUp().length, + }; + rawEndObserved.resolve(); + } + if (event.type !== "message_start" || event.message.role !== "user") return; + const content = + typeof event.message.content === "string" + ? event.message.content + : event.message.content.map(part => (part.type === "text" ? part.text : "")).join(""); + if (content === ordinaryText) { + observations.push({ kind: "ordinary", publisherFinished }); + ordinaryStarted.resolve(); + } + }); + const extensionRunner = { + hasHandlers: vi.fn((eventType: string) => eventType === "agent_end"), + hasToolResultMediation: vi.fn().mockReturnValue(false), + emitBeforeAgentStart: vi.fn().mockResolvedValue({ messages: [] }), + emit: vi.fn(async (event: unknown, _continueWhile: unknown, deliveryScope?: { generation: number }) => { + const typedEvent = event as { type?: string; sdkRunToken?: string }; + if (typedEvent.type === "agent_end" && typedEvent.sdkRunToken !== undefined) { + publisherGeneration = deliveryScope?.generation; + publisherHeld.resolve(); + await releasePublisher.promise; + publisherFinished = true; + } + }), + }; + const settings = Settings.isolated({ "compaction.enabled": false }); + settings.setModelRole("default", `${model.provider}/${model.id}`); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + extensionRunner: extensionRunner as never, + }); + const unsubscribeTerminal = session.subscribe(event => { + if (event.type === "agent_end") + observations.push({ + kind: "terminal", + generation: event.scope?.generation, + queued: agent.snapshotFollowUp().length, + }); + }); + const sdkPrompt = session.sendUserMessage("actual SDK root", { + sdkRunCapability: createSdkRunCapability("scheduled-startup-fence-owner"), + }); + try { + await withTimeout(rawEndObserved.promise, 5_000, "actual SDK raw end before session listener"); + expect(rawObservation).toMatchObject({ + rawStreaming: false, + sessionStreaming: true, + initialQueueSize: 0, + admittedQueueSize: 1, + }); + expect(rawObservation?.generation).toBeDefined(); + if (!queueAdmission) throw new Error("Expected actual pre-barrier ordinary queue admission"); + await withTimeout(queueAdmission, 5_000, "pre-barrier ordinary admission"); + await withTimeout(publisherHeld.promise, 5_000, "actual SDK publisher owns active boundary"); + expect(publisherGeneration).toBe(rawObservation?.generation); + expect(mock.calls).toHaveLength(1); + expect(agent.snapshotFollowUp()).toHaveLength(1); + startupFence.resolve(); + expect( + await Promise.race([ordinaryStarted.promise.then(() => "started"), Bun.sleep(20).then(() => "pending")]), + ).toBe("pending"); + expect(mock.calls).toHaveLength(1); + expect(agent.snapshotFollowUp()).toHaveLength(1); + if (disposition === "remove") { + const queued = session.getQueuedMessageEntries().find(entry => entry.text === ordinaryText); + if (!queued) throw new Error("Expected original parked ordinary queue identity"); + expect(session.removeQueuedMessageForEditing(queued.id)).toBe(ordinaryText); + expect(agent.snapshotFollowUp()).toHaveLength(0); + } + releasePublisher.resolve(); + await expect(withTimeout(sdkPrompt, 5_000, "real SDK void submission settlement")).resolves.toBeUndefined(); + if (disposition === "resume") + await withTimeout(ordinaryStarted.promise, 5_000, "actual publication resumes preexisting queued task"); + await withTimeout(session.waitForIdle(), 5_000, "fenced queued task complete settlement"); + expect(mock.calls).toHaveLength(disposition === "resume" ? 2 : 1); + expect(agent.snapshotFollowUp()).toHaveLength(0); + expect( + observations.filter( + event => event.kind === "ordinary" || event.generation === rawObservation?.generation, + ), + ).toEqual([ + { kind: "terminal", generation: rawObservation?.generation, queued: 1 }, + ...(disposition === "resume" ? [{ kind: "ordinary" as const, publisherFinished: true }] : []), + ]); + } finally { + startupFence.resolve(); + releasePublisher.resolve(); + unsubscribeRaw(); + unsubscribeTerminal(); + await withTimeout( + sdkPrompt.catch(() => {}), + 5_000, + "fenced fixture submission disposal", + ); + } + }); + } + it("returns a stable handle for same-run steering and its terminal scope", async () => { const gate = Promise.withResolvers(); const toolStarted = Promise.withResolvers(); @@ -1090,12 +1242,17 @@ describe("queued promotion run identity (#4668)", () => { const fixture = buildAbortableTrackedTransitionFixture(sessionManager, undefined, true); session = fixture.session; const customStarted = Promise.withResolvers(); + const boundaryOrder: { kind: "terminal" | "ordinary"; generation?: number }[] = []; + const unsubscribeTerminal = session.subscribe(event => { + if (event.type === "agent_end") boundaryOrder.push({ kind: "terminal", generation: event.scope?.generation }); + }); const unsubscribe = session.agent.subscribe(event => { if ( event.type === "message_start" && event.message.role === "custom" && event.message.customType === "custom-fifo" ) { + boundaryOrder.push({ kind: "ordinary" }); customStarted.resolve(); } }); @@ -1116,24 +1273,202 @@ describe("queued promotion run identity (#4668)", () => { expect( await Promise.race([customStarted.promise.then(() => "started"), Bun.sleep(20).then(() => "pending")]), ).toBe("pending"); + const execution = await deferred.execution; + if (execution.disposition === "removed") throw new Error("Expected authentic deferred SDK execution"); fixture.secondGate.resolve(); await withTimeout(deferred.terminal, 5_000, "custom FIFO deferred terminal"); await withTimeout(customStarted.promise, 5_000, "custom FIFO ordinary follow-up start"); unsubscribe(); await session.waitForIdle(); + unsubscribeTerminal(); + expect( + boundaryOrder + .filter(entry => entry.kind === "ordinary" || entry.generation === execution.attemptScope.generation) + .map(entry => entry.kind), + ).toEqual(["terminal", "ordinary"]); + }); + + it("does not starve a promoted SDK terminal on a different-scope message extension", async () => { + const policyTailHeld = Promise.withResolvers(); + const releasePolicyTail = Promise.withResolvers(); + let armPolicyTail = false; + let heldGeneration: number | undefined; + let extensionFinished = false; + const extensionRunner = { + hasHandlers: vi.fn((eventType: string) => eventType === "message_end"), + hasToolResultMediation: vi.fn().mockReturnValue(false), + emitBeforeAgentStart: vi.fn().mockResolvedValue({ messages: [] }), + emit: vi.fn(async (event: unknown, _continueWhile: unknown, deliveryScope?: { generation: number }) => { + const typedEvent = event as { type?: string; message?: AgentMessage }; + if ( + armPolicyTail && + typedEvent.type === "message_end" && + typedEvent.message?.role === "assistant" && + typedEvent.message.stopReason === "stop" + ) { + armPolicyTail = false; + heldGeneration = deliveryScope?.generation; + policyTailHeld.resolve(); + await releasePolicyTail.promise; + extensionFinished = true; + } + }), + }; + const fixture = buildAbortableTrackedTransitionFixture(SessionManager.inMemory(), extensionRunner, true); + session = fixture.session; + const boundaryOrder: { kind: "terminal" | "ordinary"; generation?: number }[] = []; + const unsubscribeTerminal = session.subscribe(event => { + if (event.type === "agent_end") boundaryOrder.push({ kind: "terminal", generation: event.scope?.generation }); + }); + try { + const promptDone = session.prompt("first task").catch(() => {}); + await withTimeout(fixture.firstToolStarted.promise, 5_000, "policy-tail first actual tool"); + const deferred = await session.submitUserMessage("deferred SDK", { + deliverAs: "followUp", + trackSubmission: true, + sdkRunCapability: createSdkRunCapability("new-input-owned-policy-tail"), + } as never); + await session.abort({ cause: "user_interrupt" }); + await withTimeout(promptDone, 5_000, "policy-tail initial prompt settlement"); + await withTimeout(fixture.secondToolStarted.promise, 5_000, "owned SDK successor start"); + const execution = await withTimeout(deferred.execution, 5_000, "policy-tail SDK execution acceptance"); + if (execution.disposition === "removed") throw new Error("Expected authentic SDK execution"); + armPolicyTail = true; + fixture.secondGate.resolve(); + await withTimeout(policyTailHeld.promise, 5_000, "actual SDK message policy tail"); + await withTimeout(session.agent.waitForIdle(), 5_000, "raw SDK producer end"); + await expect( + withTimeout(deferred.terminal, 5_000, "actual owned SDK terminal with other extension held"), + ).resolves.toMatchObject({ + submissionId: deferred.submissionId, + disposition: "completed", + }); + expect(heldGeneration).toBeDefined(); + expect(heldGeneration).not.toBe(execution.attemptScope.generation); + expect(extensionFinished).toBe(false); + expect(boundaryOrder.filter(entry => entry.generation === execution.attemptScope.generation)).toEqual([ + { kind: "terminal", generation: execution.attemptScope.generation }, + ]); + releasePolicyTail.resolve(); + await withTimeout(session.waitForIdle(), 5_000, "other extension settlement"); + expect(extensionFinished).toBe(true); + } finally { + releasePolicyTail.resolve(); + fixture.firstGate.resolve(); + fixture.secondGate.resolve(); + unsubscribeTerminal(); + } + }); + + it("parks a newly queued ordinary follow-up while its SDK predecessor publisher is active", async () => { + const publisherHeld = Promise.withResolvers(); + const releasePublisher = Promise.withResolvers(); + let armPublisher = false; + let publisherGeneration: number | undefined; + let publisherFinished = false; + const extensionRunner = { + hasHandlers: vi.fn((eventType: string) => eventType === "agent_end"), + hasToolResultMediation: vi.fn().mockReturnValue(false), + emitBeforeAgentStart: vi.fn().mockResolvedValue({ messages: [] }), + emit: vi.fn(async (event: unknown, _continueWhile: unknown, deliveryScope?: { generation: number }) => { + const typedEvent = event as { type?: string; sdkRunToken?: string }; + if (armPublisher && typedEvent.type === "agent_end" && typedEvent.sdkRunToken !== undefined) { + armPublisher = false; + publisherGeneration = deliveryScope?.generation; + publisherHeld.resolve(); + await releasePublisher.promise; + publisherFinished = true; + } + }), + }; + const fixture = buildAbortableTrackedTransitionFixture(SessionManager.inMemory(), extensionRunner, true); + session = fixture.session; + const ordinaryStarted = Promise.withResolvers(); + const observedBoundary: { kind: "terminal" | "ordinary"; generation?: number; publisherFinished?: boolean }[] = + []; + const unsubscribeTerminal = session.subscribe(event => { + if (event.type === "agent_end") + observedBoundary.push({ kind: "terminal", generation: event.scope?.generation }); + }); + const unsubscribeInput = session.agent.subscribe(event => { + if (event.type !== "message_start" || event.message.role !== "user") return; + const content = + typeof event.message.content === "string" + ? event.message.content + : event.message.content.map(part => (part.type === "text" ? part.text : "")).join(""); + if (content === "new ordinary while SDK publisher is held") { + observedBoundary.push({ kind: "ordinary", publisherFinished }); + ordinaryStarted.resolve(); + } + }); + try { + const promptDone = session.prompt("first task").catch(() => {}); + await withTimeout(fixture.firstToolStarted.promise, 5_000, "publisher fixture first tool"); + const deferred = await session.submitUserMessage("deferred SDK", { + deliverAs: "followUp", + trackSubmission: true, + sdkRunCapability: createSdkRunCapability("new-input-owned-publisher"), + } as never); + await session.abort({ cause: "user_interrupt" }); + await withTimeout(promptDone, 5_000, "publisher fixture initial settlement"); + await withTimeout(fixture.secondToolStarted.promise, 5_000, "publisher fixture actual SDK tool"); + const execution = await withTimeout(deferred.execution, 5_000, "publisher fixture SDK execution"); + if (execution.disposition === "removed") throw new Error("Expected authentic SDK execution"); + armPublisher = true; + fixture.secondGate.resolve(); + await withTimeout(publisherHeld.promise, 5_000, "genuine SDK publisher extension"); + expect(publisherGeneration).toBe(execution.attemptScope.generation); + await expect(withTimeout(deferred.terminal, 5_000, "actual SDK local terminal")).resolves.toMatchObject({ + submissionId: deferred.submissionId, + disposition: "completed", + }); + await withTimeout( + session.followUp("new ordinary while SDK publisher is held"), + 5_000, + "explicit ordinary queue admission", + ); + expect( + await Promise.race([ordinaryStarted.promise.then(() => "started"), Bun.sleep(20).then(() => "pending")]), + ).toBe("pending"); + expect(session.agent.snapshotFollowUp()).toHaveLength(1); + releasePublisher.resolve(); + await withTimeout(ordinaryStarted.promise, 5_000, "genuine publication resumes queued input"); + await withTimeout(session.waitForIdle(), 5_000, "publisher fixture complete settlement"); + expect( + observedBoundary.filter( + entry => entry.kind === "ordinary" || entry.generation === execution.attemptScope.generation, + ), + ).toEqual([ + { kind: "terminal", generation: execution.attemptScope.generation }, + { kind: "ordinary", publisherFinished: true }, + ]); + } finally { + releasePublisher.resolve(); + fixture.firstGate.resolve(); + fixture.secondGate.resolve(); + unsubscribeTerminal(); + unsubscribeInput(); + } }); it("keeps rearmed steering behind an older deferred SDK follow-up", async () => { const fixture = buildAbortableTrackedTransitionFixture(SessionManager.inMemory(), undefined, true); session = fixture.session; const rearmedStarted = Promise.withResolvers(); + const boundaryOrder: { kind: "terminal" | "ordinary"; generation?: number }[] = []; + const unsubscribeTerminal = session.subscribe(event => { + if (event.type === "agent_end") boundaryOrder.push({ kind: "terminal", generation: event.scope?.generation }); + }); const unsubscribe = session.agent.subscribe(event => { if (event.type !== "message_start" || event.message.role !== "user") return; const content = typeof event.message.content === "string" ? event.message.content : event.message.content.map(part => (part.type === "text" ? part.text : "")).join(""); - if (content === "rearmed steer") rearmedStarted.resolve(); + if (content === "rearmed steer") { + boundaryOrder.push({ kind: "ordinary" }); + rearmedStarted.resolve(); + } }); const promptDone = session.prompt("first task").catch(() => {}); await fixture.firstToolStarted.promise; @@ -1149,11 +1484,19 @@ describe("queued promotion run identity (#4668)", () => { expect( await Promise.race([rearmedStarted.promise.then(() => "started"), Bun.sleep(20).then(() => "pending")]), ).toBe("pending"); + const execution = await deferred.execution; + if (execution.disposition === "removed") throw new Error("Expected authentic deferred SDK execution"); fixture.secondGate.resolve(); await withTimeout(deferred.terminal, 5_000, "rearmed deferred terminal"); await withTimeout(rearmedStarted.promise, 5_000, "rearmed steer successor start"); unsubscribe(); await session.waitForIdle(); + unsubscribeTerminal(); + expect( + boundaryOrder + .filter(entry => entry.kind === "ordinary" || entry.generation === execution.attemptScope.generation) + .map(entry => entry.kind), + ).toEqual(["terminal", "ordinary"]); }); it("holds public follow-up admission behind a tracked acceptance reservation", async () => { diff --git a/packages/coding-agent/test/agent-session-queued-prompts.test.ts b/packages/coding-agent/test/agent-session-queued-prompts.test.ts index 0db4f9cc5ae..79047eeaab9 100644 --- a/packages/coding-agent/test/agent-session-queued-prompts.test.ts +++ b/packages/coding-agent/test/agent-session-queued-prompts.test.ts @@ -259,6 +259,61 @@ describe("AgentSession queued prompts (issue #434)", () => { expect(userTexts(session)).toEqual(["p1", "steer me", "queue me"]); }); + it("cancels only the implicit diverted image before queue consumption", async () => { + const gate = Promise.withResolvers(); + session = buildSession([ + async () => { + await gate.promise; + return { content: ["original completed"] }; + }, + { content: ["unrelated steer completed"] }, + ]); + const first = session.prompt("original"); + try { + await waitUntil(() => session!.agent.state.isStreaming); + await session.sendUserMessage("keep steer", { deliverAs: "steer" }); + const cancelled = new AbortController(); + const image = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ).toString("base64"); + const promotions: Array<{ startsOwnRun?: boolean; removed?: boolean }> = []; + const dispositions: Array<{ startsOwnRun: boolean }> = []; + await session.sendUserMessage( + [ + { type: "text", text: "cancel only this image" }, + { type: "image", mimeType: "image/png", data: image }, + ], + { + preflightSignal: cancelled.signal, + onDispatchDisposition: disposition => dispositions.push(disposition), + onQueuedPromoted: promotion => promotions.push(promotion), + }, + ); + expect(dispositions).toEqual([{ startsOwnRun: false }]); + expect(session.getQueuedMessages().steering).toEqual(["keep steer", "cancel only this image"]); + cancelled.abort(); + expect(session.getQueuedMessages().steering).toEqual(["keep steer"]); + expect(promotions).toEqual([{ startsOwnRun: false, removed: true }]); + expect(session.agent.state.isStreaming).toBe(true); + gate.resolve(); + await first; + await session.waitForIdle(); + expect(userTexts(session)).toEqual(["original", "keep steer"]); + expect(assistantCount(session)).toBe(2); + expect( + session.agent.state.messages.some( + message => + message.role === "user" && + Array.isArray(message.content) && + message.content.some(block => block.type === "image" && block.data === image), + ), + ).toBe(false); + } finally { + gate.resolve(); + await first; + } + }); + it("cancels only an implicit diverted text before queue consumption", async () => { const gate = Promise.withResolvers(); session = buildSession([ diff --git a/packages/coding-agent/test/fixtures/sdk-inline-image-large.png b/packages/coding-agent/test/fixtures/sdk-inline-image-large.png new file mode 100644 index 00000000000..2736011d39f Binary files /dev/null and b/packages/coding-agent/test/fixtures/sdk-inline-image-large.png differ diff --git a/packages/coding-agent/test/fixtures/task-owner-access-writer.ts b/packages/coding-agent/test/fixtures/task-owner-access-writer.ts index ef8a514c004..40b43a8e808 100644 --- a/packages/coding-agent/test/fixtures/task-owner-access-writer.ts +++ b/packages/coding-agent/test/fixtures/task-owner-access-writer.ts @@ -64,24 +64,32 @@ const store = new ManagedSessionDescendantStore( ); function publishReady(phase: WriterInput["phase"], markerPath: string, dev: bigint, ino: bigint): void { - const fd = fs.openSync(input.ready, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600); + const temporary = `${input.ready}.${process.pid}.pending`; + const fd = fs.openSync(temporary, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600); try { - fs.writeSync( - fd, - Buffer.from( - JSON.stringify({ - pid: process.pid, - phase, - marker: path.basename(markerPath), - dev: dev.toString(), - ino: ino.toString(), - }), - "utf8", - ), - ); - fs.fsyncSync(fd); + try { + fs.writeSync( + fd, + Buffer.from( + JSON.stringify({ + pid: process.pid, + phase, + marker: path.basename(markerPath), + dev: dev.toString(), + ino: ino.toString(), + }), + "utf8", + ), + ); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + // Existence is the parent's readiness signal: publish only a complete inode. + // A hard link retains O_EXCL's refusal to replace an existing ready record. + fs.linkSync(temporary, input.ready); } finally { - fs.closeSync(fd); + fs.unlinkSync(temporary); } } diff --git a/packages/coding-agent/test/helpers/sdk-adapter-dispositions-shared.ts b/packages/coding-agent/test/helpers/sdk-adapter-dispositions-shared.ts index 6ec54f0ecf5..a8a813ee891 100644 --- a/packages/coding-agent/test/helpers/sdk-adapter-dispositions-shared.ts +++ b/packages/coding-agent/test/helpers/sdk-adapter-dispositions-shared.ts @@ -131,6 +131,12 @@ export const expectedDomainErrors: Readonly> = { "compaction.run": "invalid_request", "session.handoff": "invalid_request", "session.export_html": "invalid_request", + // The parity probe supplies no upload descriptor; these machine-only controls + // must reach the host and reject the missing input rather than silently succeed. + "turn.image.begin": "invalid_input", + "turn.image.append": "invalid_input", + "turn.image.finish": "invalid_input", + "turn.image.discard": "invalid_input", "auth.login": "operation_not_session_owned", "skill.invoke": "invalid_input", "turn.result": "invalid_request", diff --git a/packages/coding-agent/test/manifests/sdk-adapter-parity-v1.json b/packages/coding-agent/test/manifests/sdk-adapter-parity-v1.json index 0e9c392435d..d155392ddfe 100644 --- a/packages/coding-agent/test/manifests/sdk-adapter-parity-v1.json +++ b/packages/coding-agent/test/manifests/sdk-adapter-parity-v1.json @@ -5288,6 +5288,390 @@ ], "expected": "forwarded" }, + { + "adapterTestId": "AD-T-C54", + "sdkId": "turn.image.begin", + "adapter": "telegram", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-T-C54", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-T-C54:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-D-C54", + "sdkId": "turn.image.begin", + "adapter": "discord", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-D-C54", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-D-C54:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-S-C54", + "sdkId": "turn.image.begin", + "adapter": "slack", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-S-C54", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-S-C54:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-M-C54", + "sdkId": "turn.image.begin", + "adapter": "mcp", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-mcp.test.ts", + "testNamePattern": "AD-M-C54", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-mcp.test.ts", + "--test-name-pattern", + "^AD-M-C54:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-A-C54", + "sdkId": "turn.image.begin", + "adapter": "acp", + "disposition": "machine_only", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-acp.test.ts", + "testNamePattern": "AD-A-C54", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-acp.test.ts", + "--test-name-pattern", + "^AD-A-C54:" + ], + "expected": "internal_only" + }, + { + "adapterTestId": "AD-L-C54", + "sdkId": "turn.image.begin", + "adapter": "daemonCli", + "disposition": "machine_only", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-daemon-cli.test.ts", + "testNamePattern": "AD-L-C54", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-daemon-cli.test.ts", + "--test-name-pattern", + "^AD-L-C54:" + ], + "expected": "internal_only" + }, + { + "adapterTestId": "AD-T-C55", + "sdkId": "turn.image.append", + "adapter": "telegram", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-T-C55", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-T-C55:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-D-C55", + "sdkId": "turn.image.append", + "adapter": "discord", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-D-C55", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-D-C55:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-S-C55", + "sdkId": "turn.image.append", + "adapter": "slack", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-S-C55", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-S-C55:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-M-C55", + "sdkId": "turn.image.append", + "adapter": "mcp", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-mcp.test.ts", + "testNamePattern": "AD-M-C55", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-mcp.test.ts", + "--test-name-pattern", + "^AD-M-C55:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-A-C55", + "sdkId": "turn.image.append", + "adapter": "acp", + "disposition": "machine_only", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-acp.test.ts", + "testNamePattern": "AD-A-C55", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-acp.test.ts", + "--test-name-pattern", + "^AD-A-C55:" + ], + "expected": "internal_only" + }, + { + "adapterTestId": "AD-L-C55", + "sdkId": "turn.image.append", + "adapter": "daemonCli", + "disposition": "machine_only", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-daemon-cli.test.ts", + "testNamePattern": "AD-L-C55", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-daemon-cli.test.ts", + "--test-name-pattern", + "^AD-L-C55:" + ], + "expected": "internal_only" + }, + { + "adapterTestId": "AD-T-C56", + "sdkId": "turn.image.finish", + "adapter": "telegram", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-T-C56", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-T-C56:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-D-C56", + "sdkId": "turn.image.finish", + "adapter": "discord", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-D-C56", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-D-C56:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-S-C56", + "sdkId": "turn.image.finish", + "adapter": "slack", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-S-C56", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-S-C56:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-M-C56", + "sdkId": "turn.image.finish", + "adapter": "mcp", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-mcp.test.ts", + "testNamePattern": "AD-M-C56", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-mcp.test.ts", + "--test-name-pattern", + "^AD-M-C56:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-A-C56", + "sdkId": "turn.image.finish", + "adapter": "acp", + "disposition": "machine_only", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-acp.test.ts", + "testNamePattern": "AD-A-C56", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-acp.test.ts", + "--test-name-pattern", + "^AD-A-C56:" + ], + "expected": "internal_only" + }, + { + "adapterTestId": "AD-L-C56", + "sdkId": "turn.image.finish", + "adapter": "daemonCli", + "disposition": "machine_only", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-daemon-cli.test.ts", + "testNamePattern": "AD-L-C56", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-daemon-cli.test.ts", + "--test-name-pattern", + "^AD-L-C56:" + ], + "expected": "internal_only" + }, + { + "adapterTestId": "AD-T-C57", + "sdkId": "turn.image.discard", + "adapter": "telegram", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-T-C57", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-T-C57:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-D-C57", + "sdkId": "turn.image.discard", + "adapter": "discord", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-D-C57", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-D-C57:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-S-C57", + "sdkId": "turn.image.discard", + "adapter": "slack", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "testNamePattern": "AD-S-C57", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions.test.ts", + "--test-name-pattern", + "^AD-S-C57:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-M-C57", + "sdkId": "turn.image.discard", + "adapter": "mcp", + "disposition": "prohibited", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-mcp.test.ts", + "testNamePattern": "AD-M-C57", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-mcp.test.ts", + "--test-name-pattern", + "^AD-M-C57:" + ], + "expected": "rejected_before_send" + }, + { + "adapterTestId": "AD-A-C57", + "sdkId": "turn.image.discard", + "adapter": "acp", + "disposition": "machine_only", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-acp.test.ts", + "testNamePattern": "AD-A-C57", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-acp.test.ts", + "--test-name-pattern", + "^AD-A-C57:" + ], + "expected": "internal_only" + }, + { + "adapterTestId": "AD-L-C57", + "sdkId": "turn.image.discard", + "adapter": "daemonCli", + "disposition": "machine_only", + "testFile": "packages/coding-agent/test/sdk-adapter-dispositions-daemon-cli.test.ts", + "testNamePattern": "AD-L-C57", + "argv": [ + "bun", + "test", + "packages/coding-agent/test/sdk-adapter-dispositions-daemon-cli.test.ts", + "--test-name-pattern", + "^AD-L-C57:" + ], + "expected": "internal_only" + }, { "adapterTestId": "AD-T-G01", "sdkId": "session.list", diff --git a/packages/coding-agent/test/notifications-topic-registry.test.ts b/packages/coding-agent/test/notifications-topic-registry.test.ts index fad3590919c..2288ef47cd7 100644 --- a/packages/coding-agent/test/notifications-topic-registry.test.ts +++ b/packages/coding-agent/test/notifications-topic-registry.test.ts @@ -781,7 +781,7 @@ test("preserves a no-provenance endpoint claim before a held create can stage it await creating; expect(reg.endpointAuthority(binding)).toEqual({ state: "unique", sessionId: "B" }); }); -test("publishes exact durable authority generation 202 at serving epoch 88", () => { +test("publishes exact durable authority generation 203 at serving epoch 88", () => { // Generation 58: parser-valid durable-fence promotion and rollback. // Generation 152: a thrown steady heartbeat renewal in the run loop is // contained instead of terminating the daemon (#4200). @@ -834,7 +834,7 @@ test("publishes exact durable authority generation 202 at serving epoch 88", () // queued or promoted work are stale. // Generation 193 gates streamed content on negotiated observer capabilities, // so existing owners do not retain the previous observer delivery contract. - expect(DAEMON_GENERATION).toBe(202); + expect(DAEMON_GENERATION).toBe(203); expect(SERVING_EPOCH).toBe(88); }); test("archives pending topics into retained inactive records", async () => { diff --git a/packages/coding-agent/test/sdk-acp-production-path.test.ts b/packages/coding-agent/test/sdk-acp-production-path.test.ts index 7d52f3db82e..e2aa30f470e 100644 --- a/packages/coding-agent/test/sdk-acp-production-path.test.ts +++ b/packages/coding-agent/test/sdk-acp-production-path.test.ts @@ -2,11 +2,14 @@ import { afterEach, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { deflateSync } from "node:zlib"; import type { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; import packageJson from "../package.json" with { type: "json" }; import { AcpAgent, acpSkillInvocation } from "../src/modes/acp/acp-agent"; import { brokerProcessIncarnation, writeBrokerDiscovery } from "../src/sdk/broker/discovery"; import { SessionIndex } from "../src/sdk/broker/session-index"; +import { PromptImageUploadStore } from "../src/sdk/host/prompt-image-upload"; +import { SessionRouter } from "../src/sdk/router/session-router"; type TestServer = { port: number | undefined; @@ -39,6 +42,46 @@ async function bounded(promise: Promise, label: string, timeoutMs = 15_000 ]); } +function originalImagePng(): Buffer { + const chunk = (name: string, data = Buffer.alloc(0)): Buffer => { + const type = Buffer.from(name, "ascii"); + let crc = 0xffffffff; + for (const byte of Buffer.concat([type, data])) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const checksum = Buffer.alloc(4); + checksum.writeUInt32BE((crc ^ 0xffffffff) >>> 0); + return Buffer.concat([length, type, data, checksum]); + }; + const width = 400; + const height = 300; + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 2; + const pixels = Buffer.alloc(height * (width * 3 + 1)); + let state = 0x31415926; + for (let row = 0; row < height; row++) { + const start = row * (width * 3 + 1); + for (let column = 1; column <= width * 3; column++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + pixels[start + column] = state & 255; + } + } + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk("IHDR", header), + chunk("IDAT", deflateSync(pixels)), + chunk("IEND"), + ]); +} + type SessionListResponse = Record; async function createSessionListBroker( @@ -284,8 +327,28 @@ test("production ACP preserves lifecycle, turn, replay, and connection ownership const skillInputs: Record[] = []; const controlOperations: string[] = []; const controlInputs: Array<{ operation: string; input: Record }> = []; + const imageUploads = new PromptImageUploadStore(() => true); + const imageFrameBytes: number[] = []; + const redeemedImages: Buffer[] = []; + const connections = new WeakMap(); + let imageEchoPresentAtDispatch = false; + let imageEchoStartIndex = 0; + let originalImageBase64 = ""; + let corruptNextImageBeginAck = false; + let malformedBeginId: string | undefined; + let holdNextImageBeginAck = false; + let releaseImageBeginAck: (() => void) | undefined; + let holdNextUploadAck: "turn.image.append" | "turn.image.finish" | undefined; + let releaseUploadAck: (() => void) | undefined; const abortFrames: Record[] = []; const updates: SessionNotification[] = []; + const expectNewDiscardedLease = (since: number): void => { + const discards = controlInputs.slice(since).filter(entry => entry.operation === "turn.image.discard"); + expect(discards).toHaveLength(1); + expect(() => imageUploads.redeem("acp-contract-reconnected", [{ id: discards[0]!.input.id }])).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + }; const providerRegistrations: Array> = []; let closeSessionTransport: (() => void) | undefined; let reconnectingSessionTransport = false; @@ -317,6 +380,7 @@ test("production ACP preserves lifecycle, turn, replay, and connection ownership websocket: { open(socket) { const connectionId = reconnectingSessionTransport ? "acp-contract-reconnected" : "acp-contract"; + connections.set(socket, connectionId); reconnectingSessionTransport = false; socket.send(JSON.stringify({ type: "hello", connectionId })); }, @@ -561,6 +625,58 @@ test("production ACP preserves lifecycle, turn, replay, and connection ownership if (typeof frame.operation === "string") controlOperations.push(frame.operation); if (typeof frame.operation === "string" && frame.input && typeof frame.input === "object") controlInputs.push({ operation: frame.operation, input: frame.input as Record }); + if (typeof frame.operation === "string" && frame.operation.startsWith("turn.image.")) { + imageFrameBytes.push(Buffer.byteLength(String(raw))); + const owner = connections.get(socket); + void (async () => { + try { + const result = + frame.operation === "turn.image.begin" + ? imageUploads.begin(owner, frame.input) + : frame.operation === "turn.image.append" + ? imageUploads.append(owner, frame.input) + : frame.operation === "turn.image.finish" + ? await imageUploads.finish(owner, frame.input) + : imageUploads.discard(owner, frame.input); + if (frame.operation === "turn.image.begin" && holdNextImageBeginAck) { + holdNextImageBeginAck = false; + releaseImageBeginAck = () => + socket.send(JSON.stringify({ type: "control_response", id: frame.id, ok: true, result })); + return; + } + if (frame.operation === holdNextUploadAck) { + holdNextUploadAck = undefined; + releaseUploadAck = () => + socket.send(JSON.stringify({ type: "control_response", id: frame.id, ok: true, result })); + return; + } + if (frame.operation === "turn.image.begin" && corruptNextImageBeginAck) { + corruptNextImageBeginAck = false; + malformedBeginId = (result as { id: string }).id; + socket.send( + JSON.stringify({ + type: "control_response", + id: frame.id, + ok: true, + result: { ...result, nextSequence: 1 }, + }), + ); + } else + socket.send(JSON.stringify({ type: "control_response", id: frame.id, ok: true, result })); + } catch (error) { + const failure = error as Error & { code?: string }; + socket.send( + JSON.stringify({ + type: "control_response", + id: frame.id, + ok: false, + error: { code: failure.code ?? "internal", message: failure.message }, + }), + ); + } + })(); + return; + } if (frame.operation === "turn.abort") abortFrames.push(frame); if (frame.operation === "model.profile.set") { const input = frame.input as Record; @@ -581,6 +697,35 @@ test("production ACP preserves lifecycle, turn, replay, and connection ownership if (typeof input.id === "string") activeModelPreset = input.id; } if (frame.operation === "turn.prompt") { + const stagedImages = (frame.input as { stagedImages?: Array<{ id: string }> }).stagedImages; + if (stagedImages) { + imageFrameBytes.push(Buffer.byteLength(String(raw))); + imageEchoPresentAtDispatch = updates + .slice(imageEchoStartIndex) + .some( + update => + update.update.sessionUpdate === "user_message_chunk" && + (update.update as { content?: { type?: string; data?: string } }).content?.type === + "image" && + (update.update as { content?: { data?: string } }).content?.data === originalImageBase64, + ); + try { + const accepted = imageUploads.redeem(connections.get(socket), stagedImages); + for (const image of accepted.images) redeemedImages.push(Buffer.from(image.data, "base64")); + accepted.release(); + } catch (error) { + const failure = error as Error & { code?: string }; + socket.send( + JSON.stringify({ + type: "control_response", + id: frame.id, + ok: false, + error: { code: failure.code ?? "internal", message: failure.message }, + }), + ); + return; + } + } promptInputs.push(frame.input as Record); promptSocket = socket; // This real-host activity frame precedes acknowledgement, so it must @@ -846,7 +991,10 @@ test("production ACP preserves lifecycle, turn, replay, and connection ownership reconnectingSessionTransport = true; closeSessionTransport!(); await waitFor( - () => providerRegistrations.length > initialProviderRegistrationCount, + () => + providerRegistrations + .slice(initialProviderRegistrationCount) + .some(registration => registration.connectionId === "acp-contract-reconnected"), "ACP provider re-registration after transport reconnect", ); await index.refresh(); @@ -935,13 +1083,15 @@ test("production ACP preserves lifecycle, turn, replay, and connection ownership expect(controlOperations).not.toContain("mode.plan.set"); expect(lifecycleInputs).toEqual([expect.objectContaining({ cwd, modelPreset: "codex-medium" })]); + const smallImageBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZYAAAAASUVORK5CYII="; let firstSettled = false; const firstPrompt = agent .prompt({ sessionId: created.sessionId, prompt: [ { type: "resource_link", name: "README", uri: "file:///workspace/README.md" }, - { type: "image", data: "image-bytes", mimeType: "image/png" }, + { type: "image", data: smallImageBase64, mimeType: "image/png" }, ], }) .then(value => { @@ -951,7 +1101,7 @@ test("production ACP preserves lifecycle, turn, replay, and connection ownership await waitFor(() => promptInputs.length === 1 && promptSocket !== undefined, "first prompt delivery"); expect(promptInputs[0]).toEqual({ text: "[Resource: README]\nURI: file:///workspace/README.md", - images: [{ data: "image-bytes", mimeType: "image/png" }], + images: [{ data: smallImageBase64, mimeType: "image/png" }], clientRef: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), }); expect(promptInputs[0].clientRef).not.toBe(skillInputs[0].clientRef); @@ -1550,5 +1700,204 @@ test("production ACP preserves lifecycle, turn, replay, and connection ownership }), ]), ); + const original = originalImagePng(); + expect(original.length).toBeGreaterThan(256 * 1024); + originalImageBase64 = original.toString("base64"); + const publicControlCount = controlOperations.length; + for (const operation of ["turn.image.begin", "turn.image.append", "turn.image.finish", "turn.image.discard"]) { + const denied = await agent.extMethod("_gjc/sdk/control", { + sessionId: created.sessionId, + operation, + input: { mimeType: "image/png", byteLength: original.length, sha256: "0".repeat(64) }, + }); + expect(denied).toMatchObject({ + ok: false, + error: { code: "invalid_input", message: expect.stringContaining("machine-local") }, + }); + } + expect(controlOperations).toHaveLength(publicControlCount); + + const imageBlock = (data: string) => ({ type: "image" as const, mimeType: "image/png", data }); + const base64Bytes = (length: number) => + "AAAA".repeat(Math.floor(length / 3)) + (length % 3 === 1 ? "AA==" : length % 3 === 2 ? "AAA=" : ""); + const beforeInvalidImages = controlOperations.length; + await expect( + agent.prompt({ sessionId: created.sessionId, prompt: Array.from({ length: 17 }, () => imageBlock("AA==")) }), + ).rejects.toMatchObject({ code: "invalid_input", message: expect.stringContaining("16 images") }); + await expect( + agent.prompt({ sessionId: created.sessionId, prompt: [imageBlock(base64Bytes(20 * 1024 * 1024 + 1))] }), + ).rejects.toMatchObject({ code: "invalid_input", message: expect.stringContaining("20 MiB") }); + const maximumImage = base64Bytes(20 * 1024 * 1024); + await expect( + agent.prompt({ sessionId: created.sessionId, prompt: Array.from({ length: 4 }, () => imageBlock(maximumImage)) }), + ).rejects.toMatchObject({ code: "invalid_input", message: expect.stringContaining("64 MiB") }); + await expect(agent.prompt({ sessionId: created.sessionId, prompt: [imageBlock("AB==")] })).rejects.toMatchObject({ + code: "invalid_input", + message: expect.stringContaining("canonical base64"), + }); + expect(controlOperations).toHaveLength(beforeInvalidImages); + + let beforeImagePrompt = promptInputs.length; + const routerPreparation = Promise.withResolvers(); + const releaseRouterPreparation = Promise.withResolvers(); + const originalRequest = SessionRouter.prototype.request; + let holdNextPromptInRouter = true; + SessionRouter.prototype.request = async function ( + this: SessionRouter, + ...args: Parameters + ) { + if (holdNextPromptInRouter && args[1].operation === "turn.prompt") { + holdNextPromptInRouter = false; + routerPreparation.resolve(); + await releaseRouterPreparation.promise; + } + return await originalRequest.apply(this, args); + }; + const beforeRouterInputs = controlInputs.length; + try { + const beforeRouterAbort = abortFrames.length; + const heldPrompt = agent.prompt({ sessionId: created.sessionId, prompt: [imageBlock(originalImageBase64)] }); + await bounded(routerPreparation.promise, "staged prompt held before Router send"); + await bounded(agent.cancel({ sessionId: created.sessionId }), "cancel during Router preparation"); + expect(await bounded(heldPrompt, "cancelled Router-prepared image prompt")).toEqual({ stopReason: "cancelled" }); + expect(abortFrames).toHaveLength(beforeRouterAbort); + expect(promptInputs).toHaveLength(beforeImagePrompt); + } finally { + releaseRouterPreparation.resolve(); + SessionRouter.prototype.request = originalRequest; + } + await waitFor(() => controlOperations.at(-1) === "turn.image.discard", "Router-held image lease discard"); + expectNewDiscardedLease(beforeRouterInputs); + expect(promptInputs).toHaveLength(beforeImagePrompt); + for (const operation of ["turn.image.append", "turn.image.finish"] as const) { + const beforeCancelAbort = abortFrames.length; + const beforeCancelUpload = controlOperations.length; + const beforeCancelInputs = controlInputs.length; + holdNextUploadAck = operation; + const pendingUpload = agent.prompt({ sessionId: created.sessionId, prompt: [imageBlock(originalImageBase64)] }); + await waitFor(() => releaseUploadAck !== undefined, `held ${operation} acknowledgement`); + await bounded(agent.cancel({ sessionId: created.sessionId }), `cancel during ${operation}`); + expect(await bounded(pendingUpload, `cancelled ${operation} prompt`)).toEqual({ stopReason: "cancelled" }); + expect(abortFrames).toHaveLength(beforeCancelAbort); + releaseUploadAck!(); + releaseUploadAck = undefined; + await waitFor( + () => controlOperations.slice(beforeCancelUpload).includes("turn.image.discard"), + `${operation} lease discard`, + ); + expectNewDiscardedLease(beforeCancelInputs); + expect(promptInputs).toHaveLength(beforeImagePrompt); + } + const beforeCancelledUpload = controlOperations.length; + const beforeCancelledInputs = controlInputs.length; + const beforeCancelledAbort = abortFrames.length; + holdNextImageBeginAck = true; + const cancelledImagePrompt = agent.prompt({ + sessionId: created.sessionId, + prompt: [imageBlock(originalImageBase64)], + }); + await waitFor(() => releaseImageBeginAck !== undefined, "held image begin acknowledgement"); + await bounded(agent.cancel({ sessionId: created.sessionId }), "cancel before image dispatch"); + expect(await bounded(cancelledImagePrompt, "cancelled staged image prompt")).toEqual({ stopReason: "cancelled" }); + expect(abortFrames).toHaveLength(beforeCancelledAbort); + releaseImageBeginAck!(); + releaseImageBeginAck = undefined; + await waitFor( + () => controlOperations.slice(beforeCancelledUpload).includes("turn.image.discard"), + "late image begin lease discard", + ); + expectNewDiscardedLease(beforeCancelledInputs); + expect(promptInputs).toHaveLength(beforeImagePrompt); + + // SdkClient adds a UUID id and Router stamps the nonempty connectionId. + // The old staged-only estimate omitted that stamp and sent a frame just over 256 KiB. + const stagedEnvelope = { + type: "control_request", + operation: "turn.prompt", + id: "00000000-0000-4000-8000-000000000000", + input: { + text: "", + stagedImages: [{ id: "00000000-0000-4000-8000-000000000000" }], + clientRef: "00000000-0000-4000-8000-000000000000", + }, + connectionId: "acp-contract-reconnected", + }; + const boundaryText = "x".repeat(256 * 1024 - Buffer.byteLength(JSON.stringify(stagedEnvelope)) + 1); + const beforeOversizeUpload = controlOperations.length; + await expect( + bounded( + agent.prompt({ + sessionId: created.sessionId, + prompt: [{ type: "text", text: boundaryText }, imageBlock(originalImageBase64)], + }), + "staged frame above transport limit", + ), + ).rejects.toMatchObject({ code: "invalid_input", message: expect.stringContaining("transport limit") }); + expect(promptInputs).toHaveLength(beforeImagePrompt); + await waitFor( + () => controlOperations.slice(beforeOversizeUpload).includes("turn.image.discard"), + "oversize staged image discard", + ); + const boundaryPrompt = agent.prompt({ + sessionId: created.sessionId, + prompt: [{ type: "text", text: boundaryText.slice(1) }, imageBlock(originalImageBase64)], + }); + await waitFor(() => promptInputs.length === beforeImagePrompt + 1, "exact 256 KiB staged prompt"); + expect(imageFrameBytes.at(-1)).toBe(256 * 1024); + promptSocket!.send( + JSON.stringify({ + type: "agent_end", + sessionId: created.sessionId, + ...currentPromptCorrelation(), + outcome: { kind: "stopped", reason: "end_turn", provenance: "agent" }, + }), + ); + expect(await bounded(boundaryPrompt, "exact-limit staged image prompt completion")).toEqual({ + stopReason: "end_turn", + }); + beforeImagePrompt = promptInputs.length; + + corruptNextImageBeginAck = true; + const beforeMalformedBegin = controlOperations.length; + await expect( + bounded( + agent.prompt({ sessionId: created.sessionId, prompt: [imageBlock(originalImageBase64)] }), + "malformed image begin acknowledgement", + ), + ).rejects.toMatchObject({ code: "invalid_prompt_acknowledgement" }); + await waitFor( + () => controlOperations.slice(beforeMalformedBegin).includes("turn.image.discard"), + "malformed image begin lease discard", + ); + expect(malformedBeginId).toEqual(expect.any(String)); + expect(() => imageUploads.redeem("acp-contract-reconnected", [{ id: malformedBeginId! }])).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + imageEchoStartIndex = updates.length; + imageEchoPresentAtDispatch = false; + const imagePrompt = agent.prompt({ + sessionId: created.sessionId, + prompt: [{ type: "image", mimeType: "image/png", data: originalImageBase64 }], + }); + await waitFor(() => promptInputs.length === beforeImagePrompt + 1, "staged image-only ACP prompt"); + expect(imageEchoPresentAtDispatch).toBe(true); + expect(promptInputs.at(-1)).toMatchObject({ text: "", stagedImages: [{ id: expect.any(String) }] }); + expect(promptInputs.at(-1)).not.toHaveProperty("images"); + expect(redeemedImages).toHaveLength(2); + expect(redeemedImages[0]?.equals(original)).toBe(true); + expect(redeemedImages[1]?.equals(original)).toBe(true); + expect(controlOperations.filter(operation => operation === "turn.image.append").length).toBeGreaterThan(1); + expect(imageFrameBytes.length).toBeGreaterThan(4); + expect(imageFrameBytes.every(bytes => bytes <= 256 * 1024)).toBe(true); + promptSocket!.send( + JSON.stringify({ + type: "agent_end", + sessionId: created.sessionId, + ...currentPromptCorrelation(), + outcome: { kind: "stopped", reason: "end_turn", provenance: "agent" }, + }), + ); + expect(await bounded(imagePrompt, "image-only ACP prompt completion")).toEqual({ stopReason: "end_turn" }); + imageUploads.close(); controller.abort(); }, 30_000); diff --git a/packages/coding-agent/test/sdk-acp-prompt-terminal.test.ts b/packages/coding-agent/test/sdk-acp-prompt-terminal.test.ts index 44db7e78957..25e6fa1489d 100644 --- a/packages/coding-agent/test/sdk-acp-prompt-terminal.test.ts +++ b/packages/coding-agent/test/sdk-acp-prompt-terminal.test.ts @@ -12,6 +12,7 @@ import { AcpAgent, acpRequestFailure } from "../src/modes/acp/acp-agent"; import { AcpSdkAdapter } from "../src/sdk/acp/adapter"; import { writeBrokerDiscovery } from "../src/sdk/broker/discovery"; import { SdkClientError } from "../src/sdk/client"; +import { PromptImageUploadStore } from "../src/sdk/host/prompt-image-upload"; import { type ExactSessionAuthorityFixture, type ExactSessionAuthorityOptions, @@ -33,6 +34,7 @@ type Fixture = { updates: SessionNotification[]; promptDelivered: Promise; busyResponseEntered: Promise; + imageBeginEntered: Promise; abortAcknowledgementEntered: Promise; thirdPromptDelivered: Promise; idleWaitScheduled: Promise; @@ -71,11 +73,14 @@ type Fixture = { releaseFailureDiagnostic(): void; releasePromptAcknowledgement(): void; releaseAbortAcknowledgement(): void; + releaseImageBegin(): void; rejectPromptAcknowledgement(): void; sendTerminal(frame: Record): void; rebindSession(): Promise; mutationInputs: Record[]; recoveryInputs: Record[]; + imageUploadIds: string[]; + redeemedImages: Buffer[]; releaseRecoveryResult(result: unknown): void; releaseRecoveryAcknowledgement(result: Record, index?: number): void; }; @@ -132,8 +137,11 @@ async function createFixture( controlledRetryBackoff?: boolean; virtualPromptWatchdog?: boolean; busyOnSecondPrompt?: boolean; + busyBeforeSecondImageRedemption?: boolean; + discardResourceGone?: boolean; busyUntilIdle?: boolean; busyAfterCancel?: boolean; + blockSecondImageBegin?: boolean; priorTranscriptUserTurn?: boolean; promptAcknowledgementError?: { code: string; @@ -144,6 +152,7 @@ async function createFixture( uncertainPromptAcknowledgement?: boolean; deferRecoveryAcknowledgement?: boolean; retainRecoveryQuery?: boolean; + acceptStagedImages?: boolean; } = {}, ): Promise { const tempDir = TempDir.createSync("@sdk-acp-prompt-terminal-"); @@ -158,6 +167,9 @@ async function createFixture( const blockedAdvisoryQueries: Array<{ socket: TestSocket; id: string; result: unknown }> = []; const mutationInputs: Record[] = []; const recoveryInputs: Record[] = []; + const imageUploadIds: string[] = []; + const redeemedImages: Buffer[] = []; + const imageUploads = options.acceptStagedImages ? new PromptImageUploadStore(() => true) : undefined; let recoveryQuery: { socket: TestSocket; id: unknown } | undefined; const recoveryAcknowledgements: Array<{ socket: TestSocket; id: unknown }> = []; const idleUpdateRelease = Promise.withResolvers(); @@ -179,6 +191,8 @@ async function createFixture( let blockNextWorkingUpdate = options.blockInitialWorkingUpdate === true; const delivered = Promise.withResolvers(); const busyResponseEntered = Promise.withResolvers(); + const imageBeginEntered = Promise.withResolvers(); + const imageBeginRelease = Promise.withResolvers(); const thirdPromptDelivered = Promise.withResolvers(); const idleWaitScheduled = Promise.withResolvers(); const abortAcknowledgementEntered = Promise.withResolvers(); @@ -390,7 +404,66 @@ async function createFixture( return; } if (frame.type !== "control_request") return; + if (typeof frame.operation === "string" && frame.operation.startsWith("turn.image.") && imageUploads) { + void (async () => { + try { + const owner = "sdk-acp-prompt-terminal"; + const result = + frame.operation === "turn.image.begin" + ? imageUploads.begin(owner, frame.input) + : frame.operation === "turn.image.append" + ? imageUploads.append(owner, frame.input) + : frame.operation === "turn.image.finish" + ? await imageUploads.finish(owner, frame.input) + : imageUploads.discard(owner, frame.input); + if (frame.operation === "turn.image.discard" && options.discardResourceGone) + throw new SdkClientError("resource_gone", "Image upload is unavailable."); + if (frame.operation === "turn.image.begin") { + imageUploadIds.push((result as { id: string }).id); + if (options.blockSecondImageBegin && imageUploadIds.length === 2) { + imageBeginEntered.resolve(); + await imageBeginRelease.promise; + } + } + socket.send(JSON.stringify({ type: "control_response", id: frame.id, ok: true, result })); + } catch (error) { + const failure = error as Error & { code?: string }; + socket.send( + JSON.stringify({ + type: "control_response", + id: frame.id, + ok: false, + error: { code: failure.code ?? "internal", message: failure.message }, + }), + ); + } + })(); + return; + } if (frame.operation === "turn.prompt" || frame.operation === "skill.invoke") { + const stagedImages = (frame.input as { stagedImages?: Array<{ id: string }> }).stagedImages; + if ( + stagedImages && + imageUploads && + !(options.busyBeforeSecondImageRedemption && promptDeliveries === 1) + ) { + try { + const reservation = imageUploads.redeem("sdk-acp-prompt-terminal", stagedImages); + for (const image of reservation.images) redeemedImages.push(Buffer.from(image.data, "base64")); + reservation.release(); + } catch (error) { + const failure = error as Error & { code?: string }; + socket.send( + JSON.stringify({ + type: "control_response", + id: frame.id, + ok: false, + error: { code: failure.code ?? "internal", message: failure.message }, + }), + ); + return; + } + } promptSocket = socket; promptDeliveries++; if (promptDeliveries === 1 || (options.busyUntilIdle && promptDeliveries === 2)) fixtureSdkIdle = false; @@ -668,6 +741,7 @@ async function createFixture( updates, promptDelivered: delivered.promise, busyResponseEntered: busyResponseEntered.promise, + imageBeginEntered: imageBeginEntered.promise, abortAcknowledgementEntered: abortAcknowledgementEntered.promise, thirdPromptDelivered: thirdPromptDelivered.promise, idleWaitScheduled: idleWaitScheduled.promise, @@ -699,6 +773,8 @@ async function createFixture( queryCalls, mutationInputs, recoveryInputs, + imageUploadIds, + redeemedImages, releaseRecoveryResult: result => { if (!recoveryQuery) throw new Error("Expected retained recovery query"); recoveryQuery.socket.send(JSON.stringify({ type: "query_response", id: recoveryQuery.id, ok: true, result })); @@ -728,6 +804,7 @@ async function createFixture( releaseAbortAcknowledgement: () => { for (const release of deferredAbortAcknowledgements.splice(0)) release(); }, + releaseImageBegin: () => imageBeginRelease.resolve(), rejectPromptAcknowledgement: () => rejectPromptAcknowledgement?.(), sendTerminal, rebindSession: async () => { @@ -744,7 +821,9 @@ async function createFixture( dispose: () => { agentMessageUpdateRelease.resolve(); failureDiagnosticRelease.resolve(); + imageBeginRelease.resolve(); abort.abort(); + imageUploads?.close(); server.stop(true); tempDir.removeSync(); }, @@ -759,6 +838,15 @@ function prompt(fixture: Fixture, text: string): Promise<{ stopReason: StoppedRe } as PromptRequest) as Promise<{ stopReason: StoppedReason }>; } +function imagePrompt(fixture: Fixture, bytes: Buffer, imageCount = 1): Promise<{ stopReason: StoppedReason }> { + const data = bytes.toString("base64"); + return fixture.agent.prompt({ + sessionId: fixture.sessionId, + messageId: "00000000-0000-4000-8000-000000000001", + prompt: Array.from({ length: imageCount }, () => ({ type: "image", mimeType: "image/png", data })), + } as PromptRequest) as Promise<{ stopReason: StoppedReason }>; +} + async function promptWhenDelivered( fixture: Fixture, text: string, @@ -874,6 +962,205 @@ test("ACP waits for SDK idle before retrying a busy successor", async () => { } }); +for (const method of ["uploadImageBegin", "uploadImageAppend", "uploadImageFinish"] as const) { + test(`ACP ${method} rejection emits no user echo or turn dispatch`, async () => { + const fixture = await createFixture({ acceptStagedImages: true }); + const rejection = vi + .spyOn(AcpSdkAdapter.prototype, method) + .mockRejectedValue(new SdkClientError("invalid_input", "Image staging rejected.")); + const cancel = vi.spyOn(AcpSdkAdapter.prototype, "cancel"); + try { + const bytes = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + await expect( + bounded( + fixture.agent.prompt({ + sessionId: fixture.sessionId, + prompt: [ + { type: "text", text: "must not appear" }, + { type: "image", mimeType: "image/png", data: bytes.toString("base64") }, + ], + } as PromptRequest), + "rejected image staging", + ), + ).rejects.toMatchObject({ code: "invalid_input" }); + expect(rejection).toHaveBeenCalledTimes(1); + expect(fixture.updates.filter(update => update.update.sessionUpdate === "user_message_chunk")).toHaveLength(0); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(cancel).not.toHaveBeenCalled(); + } finally { + rejection.mockRestore(); + cancel.mockRestore(); + fixture.dispose(); + } + }); +} + +test("ACP staged envelope overflow cannot leave a user echo", async () => { + const fixture = await createFixture({ acceptStagedImages: true }); + const cancel = vi.spyOn(AcpSdkAdapter.prototype, "cancel"); + try { + const bytes = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + await expect( + bounded( + fixture.agent.prompt({ + sessionId: fixture.sessionId, + prompt: [ + { type: "text", text: "x".repeat(256 * 1024) }, + { type: "image", mimeType: "image/png", data: bytes.toString("base64") }, + ], + } as PromptRequest), + "oversize staged envelope", + ), + ).rejects.toMatchObject({ code: "invalid_input" }); + expect(fixture.updates.filter(update => update.update.sessionUpdate === "user_message_chunk")).toHaveLength(0); + expect(fixture.promptDeliveryCount()).toBe(0); + expect(cancel).not.toHaveBeenCalled(); + } finally { + cancel.mockRestore(); + fixture.dispose(); + } +}); + +test.each([ + "post-redemption", + "pre-redemption", + "already-gone", + "cancel-before-idle", + "discard-failure", +] as const)("ACP retires one-shot refs before confirmed busy retry (%s)", async mode => { + const imageCount = mode === "post-redemption" ? 1 : 16; + const fixture = await createFixture({ + busyBeforeSecondImageRedemption: mode !== "post-redemption", + discardResourceGone: mode === "already-gone", + busyOnSecondPrompt: true, + busyUntilIdle: true, + priorTranscriptUserTurn: true, + virtualPromptWatchdog: true, + acceptStagedImages: true, + }); + const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const adapterCancel = vi.spyOn(AcpSdkAdapter.prototype, "cancel"); + const discard = vi.spyOn(AcpSdkAdapter.prototype, "uploadImageDiscard"); + if (mode === "discard-failure") discard.mockRejectedValue(new Error("sensitive image contents")); + try { + const first = prompt(fixture, "provider failure"); + await bounded(fixture.promptDelivered, "failed prompt delivery"); + fixture.sendFailed("prompt_failed", undefined, "server_is_overloaded"); + await expect(bounded(first, "failed prompt settlement")).rejects.toMatchObject({ code: "prompt_failed" }); + + const bytes = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + const successor = imagePrompt(fixture, bytes, imageCount); + await waitFor(() => fixture.promptDeliveryCount() === 2, "busy image prompt delivery"); + await bounded(fixture.busyResponseEntered, "confirmed busy image response"); + if (mode === "discard-failure") { + await expect(bounded(successor, "unconfirmed cleanup rejects retry")).rejects.toMatchObject({ + code: "image_cleanup_failed", + }); + expect(fixture.promptDeliveryCount()).toBe(2); + expect(fixture.imageUploadIds).toHaveLength(imageCount); + expect(discard).toHaveBeenCalledTimes(imageCount); + expect(warn.mock.calls.filter(args => args[0] === "acp_image_discard_failed")).toHaveLength(imageCount); + expect(JSON.stringify(warn.mock.calls)).not.toContain("sensitive image contents"); + return; + } + await bounded(fixture.idleWaitScheduled, "busy image idle wait"); + if (mode === "already-gone") { + expect(discard).toHaveBeenCalledTimes(imageCount); + await Promise.all( + discard.mock.results.map(result => expect(result.value).rejects.toMatchObject({ code: "resource_gone" })), + ); + expect(warn.mock.calls.filter(args => args[0] === "acp_image_discard_failed")).toHaveLength(0); + } + expect(fixture.redeemedImages).toEqual(mode === "post-redemption" ? [bytes] : []); + if (mode === "cancel-before-idle") { + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "cancel after pre-redemption busy"); + expect(await bounded(successor, "cancelled busy image")).toEqual({ stopReason: "cancelled" }); + expect(adapterCancel).not.toHaveBeenCalled(); + expect(fixture.imageUploadIds).toHaveLength(imageCount); + } + fixture.sendIdle(); + const completed = mode === "cancel-before-idle" ? imagePrompt(fixture, bytes, imageCount) : successor; + await waitFor(() => fixture.promptDeliveryCount() === 3, "restaged image prompt delivery"); + + expect(fixture.imageUploadIds).toHaveLength(imageCount * 2); + expect(new Set(fixture.imageUploadIds).size).toBe(imageCount * 2); + expect(fixture.redeemedImages).toEqual( + mode === "post-redemption" ? [bytes, bytes] : Array.from({ length: imageCount }, () => bytes), + ); + expect(fixture.mutationInputs.slice(1).map(input => input.stagedImages)).toEqual([ + fixture.imageUploadIds.slice(0, imageCount).map(id => ({ id })), + fixture.imageUploadIds.slice(imageCount).map(id => ({ id })), + ]); + expect( + fixture.updates.filter( + update => + update.update.sessionUpdate === "user_message_chunk" && + (update.update as { content: { type: string } }).content.type === "image", + ), + ).toHaveLength(imageCount * (mode === "cancel-before-idle" ? 2 : 1)); + fixture.sendStopped("end_turn"); + expect(await bounded(completed, "busy image successor settlement")).toEqual({ stopReason: "end_turn" }); + } finally { + discard.mockRestore(); + adapterCancel.mockRestore(); + warn.mockRestore(); + fixture.dispose(); + } +}); + +test("ACP cancels during busy image restaging without dispatching after cancellation", async () => { + const fixture = await createFixture({ + busyOnSecondPrompt: true, + busyUntilIdle: true, + priorTranscriptUserTurn: true, + virtualPromptWatchdog: true, + acceptStagedImages: true, + blockSecondImageBegin: true, + }); + const adapterCancel = vi.spyOn(AcpSdkAdapter.prototype, "cancel"); + try { + const first = prompt(fixture, "provider failure"); + await bounded(fixture.promptDelivered, "failed prompt delivery"); + fixture.sendFailed("prompt_failed", undefined, "server_is_overloaded"); + await expect(bounded(first, "failed prompt settlement")).rejects.toMatchObject({ code: "prompt_failed" }); + + const bytes = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + const successor = imagePrompt(fixture, bytes); + await waitFor(() => fixture.promptDeliveryCount() === 2, "busy image prompt delivery"); + await bounded(fixture.busyResponseEntered, "confirmed busy image response"); + await bounded(fixture.idleWaitScheduled, "busy image idle wait"); + fixture.sendIdle(); + await bounded(fixture.imageBeginEntered, "restaging image begin"); + + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "cancel while image restaging"); + expect(adapterCancel).not.toHaveBeenCalled(); + fixture.releaseImageBegin(); + expect(await bounded(successor, "cancelled image successor")).toEqual({ stopReason: "cancelled" }); + expect(fixture.promptDeliveryCount()).toBe(2); + expect(fixture.imageUploadIds).toHaveLength(2); + expect(fixture.redeemedImages).toEqual([bytes]); + expect( + fixture.updates.filter( + update => + update.update.sessionUpdate === "user_message_chunk" && + (update.update as { content: { type: string } }).content.type === "image", + ), + ).toHaveLength(1); + } finally { + adapterCancel.mockRestore(); + fixture.releaseImageBegin(); + fixture.dispose(); + } +}); + test("ACP preserves the original busy error when idle wait expires", async () => { const fixture = await createFixture({ busyOnSecondPrompt: true, @@ -1367,6 +1654,43 @@ test("ACP retries a first-turn prompt_failed after the turn started, then recove } }); +test("ACP restages original large image with fresh refs after confirmed first-turn readiness failure", async () => { + const fixture = await createFixture({ acceptStagedImages: true, controlledRetryBackoff: true }); + try { + const bytes = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + expect(bytes.length).toBeGreaterThan(256 * 1024); + const pending = imagePrompt(fixture, bytes); + await bounded(fixture.promptDelivered, "first image prompt delivery"); + expect(fixture.imageUploadIds).toHaveLength(1); + expect(fixture.redeemedImages).toEqual([bytes]); + fixture.sendTerminal({ + type: "agent_start", + sessionId: fixture.sessionId, + commandId: "prompt-terminal-command", + turnId: "prompt-terminal-turn", + }); + fixture.sendReadinessFailure(); + await bounded(fixture.retryBackoffScheduled, "confirmed image retry backoff"); + expect(fixture.promptDeliveryCount()).toBe(1); + fixture.fireRetryBackoff(); + await waitFor(() => fixture.promptDeliveryCount() === 2, "restaged image prompt delivery"); + expect(fixture.imageUploadIds).toHaveLength(2); + expect(new Set(fixture.imageUploadIds).size).toBe(2); + expect(fixture.redeemedImages).toEqual([bytes, bytes]); + expect(fixture.mutationInputs.map(input => input.stagedImages)).toEqual( + fixture.imageUploadIds.map(id => [{ id }]), + ); + expect(new Set(fixture.mutationInputs.map(input => input.clientRef)).size).toBe(2); + expect(fixture.updates.filter(update => update.update.sessionUpdate === "user_message_chunk")).toHaveLength(1); + fixture.sendStopped("end_turn"); + expect(await bounded(pending, "confirmed large-image retry completion")).toEqual({ stopReason: "end_turn" }); + } finally { + fixture.dispose(); + } +}); + test("ACP rejects a concurrent prompt during the first-turn retry backoff window (review P1)", async () => { const fixture = await createFixture({ controlledRetryBackoff: true }); try { @@ -3301,10 +3625,22 @@ test("ACP settles an acknowledged cancel as cancelled when a stopped terminal ar const fixture = await createFixture({ cancelSettlementGraceMs: 1_000 }); try { const pending = prompt(fixture, "cancel with terminal"); + let settled = false; + void pending.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); await bounded(fixture.promptDelivered, "prompt delivery"); await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "cancel acknowledgement"); + await Bun.sleep(0); + expect(settled).toBe(false); fixture.sendStopped("refusal"); expect(await bounded(pending, "terminal settlement")).toEqual({ stopReason: "cancelled" }); + expect(settled).toBe(true); } finally { fixture.dispose(); } @@ -3513,7 +3849,7 @@ test("ACP activity idle alone does not settle a prompt", async () => { async function createRecoveryFixture( acknowledgement: "held" | "rejected" | "accepted", blockedAgentMessageText?: string, - options: { controlledRetryBackoff?: boolean } = {}, + options: { controlledRetryBackoff?: boolean; acceptStagedImages?: boolean } = {}, ): Promise { let notify: ((error: SdkClientError) => void) | undefined; const original = AcpSdkAdapter.prototype.onReconnectFailed; @@ -3532,6 +3868,7 @@ async function createRecoveryFixture( blockedAgentMessageText, cancelSettlementGraceMs: 25, controlledRetryBackoff: options.controlledRetryBackoff, + acceptStagedImages: options.acceptStagedImages, }); const callback = notify; if (!callback) throw new Error("Expected session reconnect failure subscription"); @@ -3697,6 +4034,42 @@ test("ACP refreshes a stale Router attachment before retained recovery without r } }); +test("ACP never replays a large image after uncertain acknowledgement and attachment replacement", async () => { + const fixture = await createRecoveryFixture("rejected", undefined, { acceptStagedImages: true }); + try { + const bytes = Buffer.from( + await Bun.file(path.join(import.meta.dir, "fixtures/sdk-inline-image-large.png")).arrayBuffer(), + ); + expect(bytes.length).toBeGreaterThan(256 * 1024); + const pending = imagePrompt(fixture, bytes); + void pending.catch(() => undefined); + await bounded(fixture.promptDelivered, "uncertain image delivery"); + expect(fixture.imageUploadIds).toHaveLength(1); + expect(fixture.redeemedImages).toEqual([bytes]); + await fixture.rebindSession(); + fixture.notify("uncertain_after_send"); + await waitFor(() => fixture.recoveryInputs.length === 1, "uncertain image reconciliation"); + expect(fixture.recoveryInputs[0]).toEqual({ kind: "prompt", clientRef: fixture.mutationInputs[0]?.clientRef }); + fixture.releaseRecoveryResult({ ...retainedTerminal(fixture), status: "unknown" }); + await expect(bounded(pending, "uncertain image refusal")).rejects.toMatchObject({ code: "terminal_uncertain" }); + expect(fixture.promptDeliveryCount()).toBe(1); + expect(fixture.imageUploadIds).toHaveLength(1); + expect(fixture.redeemedImages).toEqual([bytes]); + expect( + fixture.updates.filter( + update => + update.update.sessionUpdate === "user_message_chunk" && + (update.update as { content: { type: string } }).content.type === "image", + ), + ).toHaveLength(1); + await expect(prompt(fixture, "must not replay image after unknown outcome")).rejects.toMatchObject({ + code: "not_found", + }); + } finally { + fixture.dispose(); + } +}); + test("ACP does not retry a recovered startup-readiness failure that carries final text", async () => { const fixture = await createRecoveryFixture("accepted", undefined, { controlledRetryBackoff: true }); try { @@ -4190,9 +4563,11 @@ for (const lateAck of ["identical", "mismatching"] as const) { const promptSpy = vi.spyOn(AcpSdkAdapter.prototype, "prompt").mockImplementation(async function ( this: AcpSdkAdapter, input, + beforeDispatch, + onDispatch, ) { const first = ++calls === 1; - const acknowledgement = await originalPrompt.call(this, input); + const acknowledgement = await originalPrompt.call(this, input, beforeDispatch, onDispatch); if (first) firstAck.resolve(); return acknowledgement; }); diff --git a/packages/coding-agent/test/sdk-broker.test.ts b/packages/coding-agent/test/sdk-broker.test.ts index 2d8904820b6..dd8a77e26a9 100644 --- a/packages/coding-agent/test/sdk-broker.test.ts +++ b/packages/coding-agent/test/sdk-broker.test.ts @@ -3085,6 +3085,7 @@ describe("SDK broker identity and discovery", () => { }); expect(await fs.readFile(external, "utf8")).toContain('"requested"'); expect((await fs.stat(externalArtifacts)).isDirectory()).toBe(true); + await managedSessionPath(dir, cwd, "legacy-owner-authority"); const legacyDirectory = path.join(getSessionsDir(dir), `--${cwd.replace(/^\//, "").replace(/[/:]/g, "-")}--`); const legacyReplayPath = path.join(legacyDirectory, "legacy-replay.jsonl"); await fs.mkdir(legacyDirectory, { recursive: true }); @@ -3378,6 +3379,7 @@ describe("SDK broker identity and discovery", () => { const transition = broker.ledger.transition.bind(broker.ledger); let calls = 0; let canonicalInjected = false; + let artifactPhaseSessionId: string | undefined; let plannedArtifactAlias: string | undefined; let postOperationArtifactAlias: string | undefined; await fs.mkdir(path.dirname(sessionPath), { recursive: true }); @@ -3386,8 +3388,15 @@ describe("SDK broker identity and discovery", () => { await broker.start(); const transitionSpy = vi.spyOn(broker.ledger, "transition").mockImplementation(async (...args) => { const result = await transition(...args); - // #6339 reworded the durable artifact-completion response to "artifacts are removed". - if (!canonicalInjected && JSON.stringify(args[2]?.response).includes("artifacts are removed")) { + const response = args[2]?.response as BrokerResponse | undefined; + if ( + !canonicalInjected && + response && + !response.ok && + response.error.code === "cleanup_pending" && + response.error.cleanup?.artifactsRemoved === true && + response.error.cleanup.phase === "transcript" + ) { canonicalInjected = true; await fs.mkdir(artifactsDir); await fs.writeFile(path.join(artifactsDir, ".reappeared"), "reappeared"); @@ -3398,8 +3407,14 @@ describe("SDK broker identity and discovery", () => { calls++; if (calls === 1) { plannedArtifactAlias = target.plannedArtifactsPath; + artifactPhaseSessionId = target.sessionId; await fs.rmdir(artifactsDir); - return { kind: "artifacts_removed", phase: "artifacts", transcriptIdentity: target.transcriptIdentity }; + return { + kind: "artifacts_removed", + phase: "artifacts", + transcriptIdentity: target.transcriptIdentity, + taskArtifactOwnerDeletionEvidence: target.taskArtifactOwnerDeletionEvidence, + }; } if (calls === 2) throw new SessionDeleteVerificationError( @@ -3430,6 +3445,8 @@ describe("SDK broker identity and discovery", () => { error: { code: "cleanup_pending", cleanup: { artifactsRemoved: true, phase: "transcript", sessionId } }, }); expect(JSON.stringify(pending)).not.toContain('"retainedArtifactsRootOnly":true'); + expect(artifactPhaseSessionId).toBe(sessionId); + expect(canonicalInjected).toBe(true); expect(await fs.readFile(path.join(artifactsDir, ".reappeared"), "utf8")).toBe("reappeared"); const repeatedPending = await broker.handleRequest( "session.delete", @@ -3980,9 +3997,12 @@ describe("SDK broker identity and discovery", () => { expect(calls).toBe(2); const transcriptParent = path.dirname(sessionPath); const renamedTranscriptParent = `${transcriptParent}.renamed`; + const originalTranscriptParentIdentity = await fs.stat(transcriptParent, { bigint: true }); await fs.rename(transcriptParent, renamedTranscriptParent); // The replacement must still pass managed-scope security (#6339) so replay reaches receipt validation. await fs.mkdir(transcriptParent, { mode: 0o700 }); + const replacementTranscript = "foreign replacement transcript must survive"; + await Bun.write(sessionPath, replacementTranscript); const replacedParentReplay = await broker.handleRequest( "session.delete", { sessionId, sessionPath, cwd }, @@ -3990,9 +4010,21 @@ describe("SDK broker identity and discovery", () => { ); expect(replacedParentReplay).toMatchObject({ ok: false, - error: { code: "cleanup_pending", cleanup: { phase: "transcript" } }, + error: { + code: "cleanup_pending", + cleanup: { + phase: "transcript", + retainedTranscriptUnknownPath: retainedSidePath, + transcriptParentIdentity: { + dev: String(originalTranscriptParentIdentity.dev), + ino: String(originalTranscriptParentIdentity.ino), + }, + }, + }, }); expect(calls).toBe(2); + expect(await fs.readdir(transcriptParent)).toEqual([path.basename(sessionPath)]); + expect(await Bun.file(sessionPath).text()).toBe(replacementTranscript); await fs.rm(transcriptParent, { recursive: true, force: true }); await fs.rename(renamedTranscriptParent, transcriptParent); expect(await fs.readFile(path.join(retainedSidePath, ".payload"), "utf8")).toBe("payload"); diff --git a/packages/coding-agent/test/sdk-control-dispatch.test.ts b/packages/coding-agent/test/sdk-control-dispatch.test.ts index 70313f67410..e4d109239d9 100644 --- a/packages/coding-agent/test/sdk-control-dispatch.test.ts +++ b/packages/coding-agent/test/sdk-control-dispatch.test.ts @@ -9,6 +9,10 @@ import { OPERATIONS } from "../src/sdk/protocol/operation-registry"; const methodByOperation: Record = { "turn.prompt": "prompt", + "turn.image.begin": "imageBegin", + "turn.image.append": "imageAppend", + "turn.image.finish": "imageFinish", + "turn.image.discard": "imageDiscard", "turn.steer": "steer", "turn.follow_up": "followUp", "turn.abort": "abort", diff --git a/packages/coding-agent/test/sdk-host-wiring.test.ts b/packages/coding-agent/test/sdk-host-wiring.test.ts index 09de24b42e5..66f14ced97c 100644 --- a/packages/coding-agent/test/sdk-host-wiring.test.ts +++ b/packages/coding-agent/test/sdk-host-wiring.test.ts @@ -29,6 +29,7 @@ import type { ExtensionContextActions, ExtensionUIContext, } from "../src/extensibility/extensions/types"; +import { PromptImageUploadStore } from "../src/sdk/host/prompt-image-upload"; test("extension API cannot set the private recovery bypass", () => { const api = undefined as ExtensionAPI | undefined; @@ -88,6 +89,7 @@ import { SESSION_HOST_OBSERVER_CAPABILITY, SessionSdkHost, } from "../src/sdk/host"; +import { TypedControlError } from "../src/sdk/host/control"; import { createSdkRunCapability } from "../src/sdk/host/sdk-run-capability"; import { type SessionAttachment, SessionRouter } from "../src/sdk/router/session-router"; import { createAgentSession } from "../src/sdk/session"; @@ -2386,75 +2388,1118 @@ test("SDK host preserves positioned live order and replay parity for every attac }); test("SDK host preserves ordered prompt image blocks in the host payload", async () => { - const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-sdk-prompt-images-")); + const releases: string[] = []; + const originalRedeem = PromptImageUploadStore.prototype.redeem; + const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function ( + this: PromptImageUploadStore, + owner, + ids, + ) { + const reservation = originalRedeem.call(this, owner, ids); + return { + images: reservation.images, + release: () => { + releases.push("released"); + reservation.release(); + }, + }; + }); + try { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-sdk-prompt-images-")); + dirs.push(cwd); + const sessionId = `sdk-prompt-images-${Date.now()}`; + const sent: CapturedSendCall[] = []; + const live = { idle: true }; + const sessionContext = context(cwd, sessionId, "main", live); + const handlers = start(sessionContext, undefined, (...args) => { + captureInternalSend(sent, args[0], args[1]); + }); + const endpointFile = path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.json`); + await waitFor(() => fs.existsSync(endpointFile), "SDK endpoint"); + const endpoint = JSON.parse(fs.readFileSync(endpointFile, "utf8")) as { url: string; token: string }; + const frames: Record[] = []; + const socket = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`); + sockets.push(socket); + socket.addEventListener("message", event => frames.push(JSON.parse(String(event.data)))); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("WS error")), { once: true }); + }); + + const control = async (requestId: string, operation: string, input: Record) => { + const frame = JSON.stringify({ + type: "control_request", + id: requestId, + operation, + input, + }); + expect(Buffer.byteLength(frame)).toBeLessThan(256 * 1024); + socket.send(frame); + await waitFor( + () => frames.some(frame => frame.type === "control_response" && frame.id === requestId), + `${requestId} response`, + ); + return frames.find(frame => frame.type === "control_response" && frame.id === requestId)!; + }; + + const prompt = async (requestId: string, input: Record) => { + socket.send( + JSON.stringify({ + type: "control_command", + sessionId, + token: endpoint.token, + requestId, + command: { type: "control_request", id: requestId, operation: "turn.prompt", input }, + }), + ); + await waitFor( + () => frames.some(frame => frame.type === "control_command_result" && frame.requestId === requestId), + `${requestId} response`, + ); + }; + + await prompt("text-and-images", { + text: "Compare these screenshots.", + images: [{ data: "cG5nLWJ5dGVz", mimeType: "image/png" }, { data: "ZGVmYXVsdC1taW1l" }], + }); + await handlers.get("agent_start")?.({ type: "agent_start" }, sessionContext); + await handlers.get("agent_end")?.({ type: "agent_end" }, sessionContext); + await prompt("images-only", { + text: "", + images: [{ data: "d2VicC1ieXRlcw", mimeType: "image/webp" }], + }); + await handlers.get("agent_start")?.({ type: "agent_start" }, sessionContext); + await handlers.get("agent_end")?.({ type: "agent_end" }, sessionContext); + const original = Buffer.from( + await Bun.file(new URL("./fixtures/sdk-inline-image-large.png", import.meta.url)).arrayBuffer(), + ); + expect(original.length).toBeGreaterThan(256 * 1024); + const digest = Buffer.from(await crypto.subtle.digest("SHA-256", original)).toString("hex"); + const stage = async (name: string): Promise => { + const begun = await control(`${name}-begin`, "turn.image.begin", { + mimeType: "image/png", + byteLength: original.length, + sha256: digest, + }); + const uploadId = (begun.result as { id: string }).id; + expect(uploadId).toMatch(/^[0-9a-f]{8}-/); + expect(begun).toMatchObject({ ok: true, result: { id: uploadId, nextSequence: 0 } }); + let sequence = 0; + for (let offset = 0; offset < original.length; offset += 96 * 1024) { + const response = await control(`${name}-chunk-${sequence}`, "turn.image.append", { + id: uploadId, + sequence, + data: original.subarray(offset, offset + 96 * 1024).toString("base64"), + }); + expect(response).toMatchObject({ ok: true, result: { nextSequence: ++sequence } }); + } + expect(await control(`${name}-finish`, "turn.image.finish", { id: uploadId })).toMatchObject({ ok: true }); + return uploadId; + }; + const uploadId = await stage("stage"); + expect( + await control("staged-image", "turn.prompt", { text: "Read this image", stagedImages: [{ id: uploadId }] }), + ).toMatchObject({ ok: true, result: { accepted: true } }); + expect(releases).toEqual([]); + expect((sent[2]?.[0] as Array<{ type: string; data?: string }>)[1]?.data).toBe(original.toString("base64")); + + expect(sent).toEqual([ + [ + [ + { type: "text", text: "Compare these screenshots." }, + { type: "image", data: "cG5nLWJ5dGVz", mimeType: "image/png" }, + { type: "image", data: "ZGVmYXVsdC1taW1l", mimeType: "image/jpeg" }, + ], + { + preflightSignal: expect.any(AbortSignal), + onQueuedPromoted: expect.any(Function), + onDispatchDisposition: expect.any(Function), + }, + ], + [ + [{ type: "image", data: "d2VicC1ieXRlcw", mimeType: "image/webp" }], + { + preflightSignal: expect.any(AbortSignal), + onQueuedPromoted: expect.any(Function), + onDispatchDisposition: expect.any(Function), + }, + ], + [ + [ + { type: "text", text: "Read this image" }, + { type: "image", data: original.toString("base64"), mimeType: "image/png" }, + ], + { + preflightSignal: expect.any(AbortSignal), + onQueuedPromoted: expect.any(Function), + onDispatchDisposition: expect.any(Function), + }, + ], + ]); + await handlers.get("agent_start")?.({ type: "agent_start" }, sessionContext); + const rejectedId = await stage("rejected"); + expect( + await control("rejected-image", "turn.prompt", { + text: "Reject", + stagedImages: [{ id: rejectedId }], + clientRef: " ", + }), + ).toMatchObject({ ok: false, error: { code: "invalid_input" } }); + expect(sent).toHaveLength(3); + expect(releases).toHaveLength(1); + live.idle = false; + const busyId = await stage("busy"); + expect( + await control("busy-image", "turn.prompt", { + text: "Busy", + stagedImages: [{ id: busyId }], + }), + ).toMatchObject({ ok: false, error: { code: "busy" } }); + expect(sent).toHaveLength(3); + expect(releases).toHaveLength(2); + await handlers.get("agent_end")?.({ type: "agent_end" }, sessionContext); + expect(releases).toHaveLength(3); + await handlers.get("agent_end")?.({ type: "agent_end" }, sessionContext); + expect(releases).toHaveLength(3); + live.idle = true; + const racedId = await stage("race"); + expect( + await control("raced-image", "turn.prompt", { + text: "Diverted after idle snapshot", + stagedImages: [{ id: racedId }], + }), + ).toMatchObject({ ok: true, result: { accepted: true } }); + expect(releases).toHaveLength(3); + expect(sent).toHaveLength(4); + // The agent queue can remove a prompt diverted after the host's idle snapshot. + sent[3]?.[1]?.onQueuedPromoted?.({ startsOwnRun: false, removed: true }); + expect(releases).toHaveLength(4); + await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext); + expect(releases).toHaveLength(4); + } finally { + redeemSpy.mockRestore(); + } +}); + +test("fatal prompt claim failure releases accepted image capacity without publishing a terminal", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-sdk-fatal-image-")); + dirs.push(cwd); + const sessionId = `sdk-fatal-image-${Date.now()}`; + const sessionFile = path.join(cwd, "session.jsonl"); + const sessionContext = context(cwd, sessionId); + const sessionManager = sessionContext.sessionManager as Record; + sessionContext.sessionManager = { ...sessionManager, getSessionFile: () => sessionFile }; + const failedCommit = failNextReconciliationCommit(sessionFile, sessionId); + const warnSpy = spyOn(logger, "warn").mockImplementation(() => {}); + const originalRedeem = PromptImageUploadStore.prototype.redeem; + let reserved = 0; + let releases = 0; + // Use the real upload/redeem path, with a one-reservation quota so a + // retained accepted image deterministically blocks the next connection. + const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function ( + this: PromptImageUploadStore, + owner, + ids, + ) { + if (reserved) throw new TypedControlError("busy", "Accepted image capacity exceeded."); + const reservation = originalRedeem.call(this, owner, ids); + reserved++; + let released = false; + return { + images: reservation.images, + release: () => { + if (released) return; + released = true; + reservation.release(); + reserved--; + releases++; + }, + }; + }); + try { + const handlers = start( + sessionContext, + undefined, + async (_content, options) => { + await firePreflightAccept(options); + }, + true, + ); + const endpointFile = path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.json`); + await waitFor(() => fs.existsSync(endpointFile), "SDK endpoint"); + const endpoint = JSON.parse(fs.readFileSync(endpointFile, "utf8")) as { url: string; token: string }; + const open = async () => { + const frames: Record[] = []; + const socket = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`); + sockets.push(socket); + socket.addEventListener("message", event => frames.push(JSON.parse(String(event.data)))); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("WS error")), { once: true }); + }); + const control = async (id: string, operation: string, input: Record) => { + const json = JSON.stringify({ type: "control_request", id, operation, input }); + expect(Buffer.byteLength(json)).toBeLessThan(256 * 1024); + socket.send(json); + await waitFor( + () => frames.some(frame => frame.type === "control_response" && frame.id === id), + `${id} response`, + ); + return frames.find(frame => frame.type === "control_response" && frame.id === id)!; + }; + return { frames, control }; + }; + const requester = await open(); + const healthy = await open(); + const bytes = Buffer.from( + await Bun.file(new URL("./fixtures/sdk-inline-image-large.png", import.meta.url)).arrayBuffer(), + ); + const sha256 = Buffer.from(await crypto.subtle.digest("SHA-256", bytes)).toString("hex"); + const stage = async (client: typeof requester, name: string) => { + const begun = await client.control(`${name}-begin`, "turn.image.begin", { + mimeType: "image/png", + byteLength: bytes.length, + sha256, + }); + expect(begun).toMatchObject({ ok: true }); + const id = (begun.result as { id: string }).id; + let sequence = 0; + for (let offset = 0; offset < bytes.length; offset += 96 * 1024) { + expect( + await client.control(`${name}-chunk-${sequence}`, "turn.image.append", { + id, + sequence: sequence++, + data: bytes.subarray(offset, offset + 96 * 1024).toString("base64"), + }), + ).toMatchObject({ ok: true }); + } + expect(await client.control(`${name}-finish`, "turn.image.finish", { id })).toMatchObject({ ok: true }); + return id; + }; + const firstId = await stage(requester, "fatal"); + const nextId = await stage(healthy, "healthy"); + const accepted = await requester.control("fatal-prompt", "turn.prompt", { + text: "This terminal claim will fail", + stagedImages: [{ id: firstId }], + }); + expect(accepted).toMatchObject({ ok: true, result: { accepted: true } }); + const correlation = acceptedCorrelation(accepted); + expect(reserved).toBe(1); + await handlers.get("agent_start")?.({ type: "agent_start" }, sessionContext); + failedCommit.arm(); + await handlers.get("agent_end")?.(assistantEndEvent("must not publish"), sessionContext); + await failedCommit.failed; + await waitFor( + () => + requester.frames.some(frame => frame.type === "agent_failed" && frame.commandId === correlation.commandId), + "fatal prompt closure", + ); + const terminalFrames = () => + requester.frames.filter( + frame => + (frame.type === "agent_failed" || frame.type === "agent_end") && + frame.commandId === correlation.commandId && + frame.turnId === correlation.turnId, + ); + expect(terminalFrames()).toEqual([ + expect.objectContaining({ + type: "agent_failed", + error: { code: "terminal_uncertain", message: "Prompt reconciliation is unavailable." }, + }), + ]); + expect(reserved).toBe(0); + expect(releases).toBe(1); + const next = await healthy.control("healthy-prompt", "turn.prompt", { + text: "Capacity is available to this live socket", + stagedImages: [{ id: nextId }], + }); + expect(next).toMatchObject({ ok: true, result: { accepted: true } }); + expect(reserved).toBe(1); + expect(terminalFrames()).toHaveLength(1); + const shutdownFailure = await Promise.resolve( + handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext), + ).then( + () => undefined, + (error: unknown) => error, + ); + expect((shutdownFailure as { code?: string } | undefined)?.code).toBe("sdk_reconciliation_teardown_failed"); + } finally { + redeemSpy.mockRestore(); + warnSpy.mockRestore(); + failedCommit.restore(); + } +}, 60_000); + +test("unsettled fatal abort retains accepted images until session teardown", async () => { + for (const settlement of ["throws", "unfenced"] as const) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-sdk-unsettled-image-${settlement}-`)); + dirs.push(cwd); + const sessionId = `sdk-unsettled-image-${settlement}-${Date.now()}`; + const live = { idle: true }; + const base = context(cwd, sessionId, "main", live); + const ledger = createRunResourceLedger(); + const originalDomain = ledger.open("live-image-run"); + const successorDomain = ledger.open("successor-image-run"); + if (!originalDomain || !successorDomain) throw new Error("Test run domains could not open."); + const trustedOwners = new WeakMap(); + let abortCalls = 0; + const sessionContext = { + ...base, + sessionManager: { + ...(base.sessionManager as Record), + getSessionFile: () => path.join(cwd, "session.jsonl"), + }, + getActivePromptHandle: () => "live-image-run", + getRunOwnerDomain: (handle: string) => ledger.lookupDomain(handle), + getTerminalRunOwnerForEvent: (event: object) => trustedOwners.get(event), + getTerminalTurnEpoch: () => 1, + abortPromptAndWait: async (handle: string) => { + expect(handle).toBe("live-image-run"); + abortCalls++; + if (settlement === "throws") throw new Error("run is still holding image strings"); + return { status: "unfenced", reason: "resources_pending", pending: [] }; + }, + }; + const originalRedeem = PromptImageUploadStore.prototype.redeem; + let reserved = 0; + let releases = 0; + const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function ( + this: PromptImageUploadStore, + owner, + ids, + ) { + if (reserved) throw new TypedControlError("busy", "Accepted image capacity exceeded."); + const reservation = originalRedeem.call(this, owner, ids); + reserved++; + let released = false; + return { + images: reservation.images, + release: () => { + if (released) return; + released = true; + reservation.release(); + reserved--; + releases++; + }, + }; + }); + const warnSpy = spyOn(logger, "warn").mockImplementation(() => {}); + try { + const handlers = start( + sessionContext, + { get: () => undefined, getAgentDir: () => cwd } as unknown as Settings, + async (_content, options) => { + await firePreflightAccept(options); + }, + true, + ); + const endpointFile = path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.json`); + await waitFor(() => fs.existsSync(endpointFile), "SDK endpoint"); + const endpoint = JSON.parse(fs.readFileSync(endpointFile, "utf8")) as { url: string; token: string }; + const open = async () => { + const frames: Record[] = []; + const socket = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`); + sockets.push(socket); + socket.addEventListener("message", event => frames.push(JSON.parse(String(event.data)))); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("WS error")), { once: true }); + }); + const control = async (id: string, operation: string, input: Record) => { + socket.send(JSON.stringify({ type: "control_request", id, operation, input })); + await waitFor( + () => frames.some(frame => frame.type === "control_response" && frame.id === id), + `${id} response`, + ); + return frames.find(frame => frame.type === "control_response" && frame.id === id)!; + }; + return { socket, frames, control }; + }; + const requester = await open(); + const other = await open(); + const bytes = Buffer.from( + await Bun.file(new URL("./fixtures/sdk-inline-image-large.png", import.meta.url)).arrayBuffer(), + ); + const sha256 = Buffer.from(await crypto.subtle.digest("SHA-256", bytes)).toString("hex"); + const stage = async (client: typeof requester, name: string) => { + const begun = await client.control(`${name}-begin`, "turn.image.begin", { + mimeType: "image/png", + byteLength: bytes.length, + sha256, + }); + expect(begun).toMatchObject({ ok: true }); + const id = (begun.result as { id: string }).id; + let sequence = 0; + for (let offset = 0; offset < bytes.length; offset += 96 * 1024) { + expect( + await client.control(`${name}-chunk-${sequence}`, "turn.image.append", { + id, + sequence: sequence++, + data: bytes.subarray(offset, offset + 96 * 1024).toString("base64"), + }), + ).toMatchObject({ ok: true }); + } + expect(await client.control(`${name}-finish`, "turn.image.finish", { id })).toMatchObject({ ok: true }); + return id; + }; + const firstId = await stage(requester, "first"); + const secondId = await stage(other, "second"); + const accepted = await requester.control("live-prompt", "turn.prompt", { + text: "Keep the image in the run", + stagedImages: [{ id: firstId }], + }); + expect(accepted).toMatchObject({ ok: true, result: { accepted: true } }); + const correlation = acceptedCorrelation(accepted); + await handlers.get("agent_start")?.( + { + type: "agent_start", + runId: "live-image-run", + commandId: correlation.commandId, + turnId: correlation.turnId, + }, + sessionContext, + ); + live.idle = false; + // The abort response may be fenced with the requester; the fatal frame + // is the observable result of this deliberately unsettled run. + requester.socket.send( + JSON.stringify({ + type: "control_request", + id: "fatal-abort", + operation: "turn.abort", + input: { mode: "terminal" }, + idempotencyKey: `fatal-abort-${settlement}`, + }), + ); + await waitFor( + () => + requester.frames.some( + frame => frame.type === "agent_failed" && frame.commandId === correlation.commandId, + ), + "fatal abort closure", + ); + expect(abortCalls).toBe(1); + expect( + requester.frames.filter( + frame => + (frame.type === "agent_end" || frame.type === "agent_failed") && + frame.commandId === correlation.commandId, + ), + ).toEqual([ + expect.objectContaining({ + type: "agent_failed", + error: expect.objectContaining({ code: "terminal_uncertain" }), + }), + ]); + expect(reserved).toBe(1); + expect(releases).toBe(0); + expect( + await other.control("blocked-prompt", "turn.prompt", { + text: "Cannot overbook the still-running image", + stagedImages: [{ id: secondId }], + }), + ).toMatchObject({ ok: false, error: { code: "busy" } }); + expect(reserved).toBe(1); + // An unrelated start after the fatal record's TTL invokes delivery + // cleanup. Its terminal still has no authority over the original run. + const now = Date.now(); + const clock = spyOn(Date, "now").mockReturnValue(now + 6 * 60_000); + try { + await handlers.get("agent_start")?.({ type: "agent_start" }, sessionContext); + } finally { + clock.mockRestore(); + } + expect(reserved).toBe(1); + const successorEnd = { type: "agent_end" as const, messages: [] }; + // An earlier extension may replace the public WeakMap context. The + // SDK must trust only AgentSession's independent event proof. + setAgentTerminalOwnerContext(successorEnd, { + resourceRunId: "live-image-run", + domain: originalDomain, + }); + trustedOwners.set(successorEnd, { resourceRunId: "successor-image-run", domain: successorDomain }); + await handlers.get("agent_end")?.(successorEnd, sessionContext); + expect(reserved).toBe(1); + // Fatal closure cleared the run's correlation; an uncorrelated end + // cannot prove that this exact accepted image has been released. + await handlers.get("agent_end")?.({ type: "agent_end", messages: [] }, sessionContext); + expect(reserved).toBe(1); + if (settlement === "throws") { + const originalEnd = { type: "agent_end" as const, messages: [] }; + trustedOwners.set(originalEnd, { + resourceRunId: "live-image-run", + domain: originalDomain, + }); + await handlers.get("agent_end")?.(originalEnd, sessionContext); + expect(reserved).toBe(0); + expect(releases).toBe(1); + live.idle = true; + expect( + await other.control("recovered-prompt", "turn.prompt", { + text: "Capacity returns after the exact original run ended", + stagedImages: [{ id: secondId }], + }), + ).toMatchObject({ ok: true, result: { accepted: true } }); + expect(reserved).toBe(1); + } + const shutdownFailure = await Promise.resolve( + handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext), + ).then( + () => undefined, + (error: unknown) => error, + ); + expect(shutdownFailure).toBeUndefined(); + expect(reserved).toBe(0); + expect(releases).toBe(settlement === "throws" ? 2 : 1); + } finally { + redeemSpy.mockRestore(); + warnSpy.mockRestore(); + } + } +}, 60_000); + +test.each([ + "fatal", + "natural", + "removed", + "ordinary-abort", + "terminal-abort", + "queued-deadline", + "busy-at-dispatch", + "joined-progress", + "held-terminal", + "claim-failure", + "finalize-failure", +] as const)("a diverted image prompt keeps exact lifecycle ownership (%s)", async mode => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-sdk-steered-image-owner-")); + dirs.push(cwd); + const sessionId = `sdk-steered-image-owner-${Date.now()}`; + const live = { idle: true, handle: "original-run" }; + const base = context(cwd, sessionId, "main", live); + const ledger = createRunResourceLedger(); + const originalDomain = ledger.open("original-run"); + const successorDomain = ledger.open("successor-run"); + if (!originalDomain || !successorDomain) throw new Error("Test run domains could not open."); + const trustedOwners = new WeakMap(); + const sessionContext = { + ...base, + sessionManager: { + ...(base.sessionManager as Record), + getSessionFile: () => path.join(cwd, "session.jsonl"), + }, + getActivePromptHandle: () => live.handle, + getRunOwnerDomain: (handle: string) => ledger.lookupDomain(handle), + getTerminalRunOwnerForEvent: (event: object) => trustedOwners.get(event), + getTerminalTurnEpoch: () => 1, + abortPromptAndWait: async () => { + throw new Error("consuming run still holds image strings"); + }, + }; + const accepted = Promise.withResolvers(); + const preflight = Promise.withResolvers(); + let queued = false; + let promote: ((promotion: { startsOwnRun?: boolean; removed?: boolean }) => void) | undefined; + let releaseQueueAbort: (() => void) | undefined; + const originalRedeem = PromptImageUploadStore.prototype.redeem; + let reserved = 0; + const redeemSpy = spyOn(PromptImageUploadStore.prototype, "redeem").mockImplementation(function ( + this: PromptImageUploadStore, + owner, + ids, + ) { + const reservation = originalRedeem.call(this, owner, ids); + reserved++; + return { + images: reservation.images, + release: () => { + reserved--; + reservation.release(); + }, + }; + }); + const warnSpy = spyOn(logger, "warn").mockImplementation(() => {}); + const heldCommit = + mode === "held-terminal" + ? pauseNextReconciliationCommit(path.join(cwd, "session.jsonl"), sessionId, true) + : undefined; + const failedCommit = + mode === "claim-failure" || mode === "finalize-failure" + ? failNextReconciliationCommit(path.join(cwd, "session.jsonl"), sessionId, mode === "finalize-failure") + : undefined; + try { + const handlers = start( + sessionContext, + { + get: (key: string) => + mode === "queued-deadline" || mode === "joined-progress" + ? key === "sdk.promptDeadlineMs" + ? 100 + : key === "sdk.promptMaxRuntimeMs" + ? 1_000 + : undefined + : undefined, + getAgentDir: () => cwd, + } as unknown as Settings, + async (_content, options) => { + accepted.resolve(); + await preflight.promise; + await firePreflightAccept(options); + queued = true; + promote = options?.onQueuedPromoted; + const remove = () => { + if (!queued) return; + queued = false; + heldCommit?.arm(); + failedCommit?.arm(); + promote?.({ startsOwnRun: false, removed: true }); + }; + options?.preflightSignal?.addEventListener("abort", remove, { once: true }); + releaseQueueAbort = () => options?.preflightSignal?.removeEventListener("abort", remove); + options?.onDispatchDisposition?.({ startsOwnRun: false }); + }, + true, + ); + const endpointFile = path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.json`); + await waitFor(() => fs.existsSync(endpointFile), "SDK endpoint"); + const endpoint = JSON.parse(fs.readFileSync(endpointFile, "utf8")) as { url: string; token: string }; + const frames: Record[] = []; + const socket = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`); + sockets.push(socket); + socket.addEventListener("message", event => frames.push(JSON.parse(String(event.data)))); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("WS error")), { once: true }); + }); + const control = async (id: string, operation: string, input: Record) => { + socket.send(JSON.stringify({ type: "control_request", id, operation, input })); + await waitFor( + () => frames.some(frame => frame.type === "control_response" && frame.id === id), + `${id} response`, + ); + return frames.find(frame => frame.type === "control_response" && frame.id === id)!; + }; + const bytes = Buffer.from( + await Bun.file(new URL("./fixtures/sdk-inline-image-large.png", import.meta.url)).arrayBuffer(), + ); + const sha256 = Buffer.from(await crypto.subtle.digest("SHA-256", bytes)).toString("hex"); + const begun = await control("steered-begin", "turn.image.begin", { + mimeType: "image/png", + byteLength: bytes.length, + sha256, + }); + expect(begun).toMatchObject({ ok: true }); + const imageId = (begun.result as { id: string }).id; + let sequence = 0; + for (let offset = 0; offset < bytes.length; offset += 96 * 1024) { + expect( + await control(`steered-chunk-${sequence}`, "turn.image.append", { + id: imageId, + sequence: sequence++, + data: bytes.subarray(offset, offset + 96 * 1024).toString("base64"), + }), + ).toMatchObject({ ok: true }); + } + expect(await control("steered-finish", "turn.image.finish", { id: imageId })).toMatchObject({ ok: true }); + if (mode === "busy-at-dispatch") { + live.idle = false; + await handlers.get("agent_start")?.({ type: "agent_start" }, sessionContext); + } + socket.send( + JSON.stringify({ + type: "control_request", + id: "steered-prompt", + operation: "turn.prompt", + input: { text: "Image diverted during preflight", stagedImages: [{ id: imageId }] }, + }), + ); + if (mode === "busy-at-dispatch") { + await waitFor( + () => frames.some(frame => frame.id === "steered-prompt" && frame.type === "control_response"), + "staged busy rejection", + ); + expect(frames.find(frame => frame.id === "steered-prompt" && frame.type === "control_response")).toMatchObject( + { ok: false, error: { code: "busy" } }, + ); + expect(queued).toBe(false); + expect(reserved).toBe(0); + await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext); + return; + } + await accepted.promise; + // A different run starts while preflight is awaiting AgentSession. The + // prompt is accepted only after this run has become busy, so it is queued. + live.idle = false; + await handlers.get("agent_start")?.({ type: "agent_start" }, sessionContext); + preflight.resolve(); + await waitFor(() => frames.some(frame => frame.id === "steered-prompt"), "steered prompt admission"); + const ack = frames.find(frame => frame.id === "steered-prompt")!; + expect(ack).toMatchObject({ ok: true, result: { accepted: true } }); + const correlation = acceptedCorrelation(ack); + await waitFor(() => promote !== undefined, "exact queued owner"); + expect(reserved).toBe(1); + const queryResult = async () => { + const id = `result-${frames.length}`; + socket.send( + JSON.stringify({ + type: "query_request", + id, + query: "turn.result", + input: { kind: "prompt", ...correlation }, + }), + ); + await waitFor(() => frames.some(frame => frame.id === id), "durable diverted result"); + return frames.find(frame => frame.id === id)!; + }; + if (heldCommit || failedCommit) { + socket.send( + JSON.stringify({ + type: "control_request", + id: "persist-cancel", + operation: "turn.abort", + input: { mode: "terminal" }, + idempotencyKey: "persist-cancel", + }), + ); + if (heldCommit) { + await heldCommit.started; + expect(frames.some(frame => frame.id === "persist-cancel" && frame.type === "control_response")).toBe( + false, + ); + const before = (await Bun.file( + reconciliationStorePath(path.join(cwd, "session.jsonl"), sessionId), + ).json()) as ReconciliationStoreDocument; + expect( + before.records.find(record => record.commandId === correlation.commandId)?.terminalAt, + ).toBeUndefined(); + heldCommit.release(); + await waitFor( + () => frames.some(frame => frame.id === "persist-cancel" && frame.type === "control_response"), + "joined durable cancellation reply", + ); + expect( + frames.find(frame => frame.id === "persist-cancel" && frame.type === "control_response"), + ).toMatchObject({ ok: true }); + expect(await queryResult()).toMatchObject({ + ok: true, + result: { status: "terminal_ok", outcome: { reason: "cancelled" } }, + }); + await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext); + } else if (failedCommit) { + await failedCommit.failed; + await waitFor( + () => warnSpy.mock.calls.some(args => String(args[0]).includes("persistence failed")), + "failed durable removal", + ); + await waitFor( + () => frames.some(frame => frame.id === "persist-cancel" && frame.type === "control_response"), + "uncertain cancellation reply", + ); + const durable = (await Bun.file( + reconciliationStorePath(path.join(cwd, "session.jsonl"), sessionId), + ).json()) as ReconciliationStoreDocument; + expect( + durable.records.find(record => record.commandId === correlation.commandId)?.terminalAt, + ).toBeUndefined(); + expect(durable.terminalScopes?.at(-1)?.turnDisposition).toBe("no_effect_reserved"); + expect( + frames.find(frame => frame.id === "persist-cancel" && frame.type === "control_response"), + ).toMatchObject({ ok: true, result: { turn: "uncertain", reason: "worker_unsettled" } }); + expect(frames.some(frame => frame.type === "agent_end" && frame.commandId === correlation.commandId)).toBe( + false, + ); + const replayFrames: Record[] = []; + const replaySocket = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`); + sockets.push(replaySocket); + replaySocket.addEventListener("message", event => replayFrames.push(JSON.parse(String(event.data)))); + await waitFor(() => replaySocket.readyState === WebSocket.OPEN, "fresh cancellation replay client"); + replaySocket.send( + JSON.stringify({ + type: "control_request", + id: "persist-cancel-replay", + operation: "turn.abort", + input: { mode: "terminal" }, + idempotencyKey: "persist-cancel", + }), + ); + await waitFor( + () => replayFrames.some(frame => frame.id === "persist-cancel-replay"), + "uncertain cancellation replay", + ); + expect(replayFrames.find(frame => frame.id === "persist-cancel-replay")).toMatchObject({ + ok: true, + result: { turn: "uncertain" }, + }); + const shutdownFailure = await Promise.resolve( + handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext), + ).then( + () => undefined, + (error: unknown) => error, + ); + expect((shutdownFailure as { code?: string } | undefined)?.code).toBe("sdk_reconciliation_teardown_failed"); + } + expect(queued).toBe(false); + expect(live.handle).toBe("original-run"); + return; + } + if (mode === "queued-deadline") { + await Bun.sleep(350); + expect(await queryResult()).toMatchObject({ ok: true, result: { status: "accepted" } }); + expect(queued).toBe(true); + expect(reserved).toBe(1); + } + if (mode === "removed" || mode === "ordinary-abort" || mode === "terminal-abort") { + if (mode === "removed") { + queued = false; + promote?.({ startsOwnRun: false, removed: true }); + } else if (mode === "terminal-abort") { + socket.send( + JSON.stringify({ + type: "control_request", + id: "cancel-before-consumption", + operation: "turn.abort", + input: { mode: "terminal" }, + idempotencyKey: "cancel-before-consumption", + }), + ); + await waitFor( + () => frames.some(frame => frame.id === "cancel-before-consumption"), + "terminal cancel response", + ); + expect(frames.find(frame => frame.id === "cancel-before-consumption")).toMatchObject({ ok: true }); + } else expect(await control("cancel-before-consumption", "turn.abort", {})).toMatchObject({ ok: true }); + await waitFor( + () => frames.some(frame => frame.type === "agent_end" && frame.commandId === correlation.commandId), + "removed image terminal", + ); + expect(queued).toBe(false); + expect(reserved).toBe(0); + expect(await queryResult()).toMatchObject({ + ok: true, + result: { status: "terminal_ok", outcome: { kind: "stopped", reason: "cancelled" } }, + }); + expect(live.handle).toBe("original-run"); + await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext); + return; + } + const originalEnd = { type: "agent_end" as const, messages: [] }; + trustedOwners.set(originalEnd, { resourceRunId: "original-run", domain: originalDomain }); + await handlers.get("agent_end")?.(originalEnd, sessionContext); + expect(reserved).toBe(1); + live.handle = "successor-run"; + await handlers.get("agent_start")?.({ type: "agent_start" }, sessionContext); + expect(frames.some(frame => frame.type === "agent_start" && frame.commandId === correlation.commandId)).toBe( + false, + ); + queued = false; + releaseQueueAbort?.(); + promote?.({ startsOwnRun: false }); + if (mode === "joined-progress") { + await handlers.get("tool_execution_start")?.( + { type: "tool_execution_start", toolCallId: "joined-tool", toolName: "read", args: {} }, + sessionContext, + ); + for (let tick = 0; tick < 5; tick++) { + await Bun.sleep(60); + await handlers.get("tool_execution_update")?.( + { + type: "tool_execution_update", + toolCallId: "joined-tool", + toolName: "read", + args: {}, + partialResult: { content: [{ type: "text", text: "progress" }] }, + }, + sessionContext, + ); + expect(await queryResult()).toMatchObject({ ok: true, result: { status: "accepted" } }); + } + await handlers.get("tool_execution_end")?.( + { + type: "tool_execution_end", + toolCallId: "joined-tool", + toolName: "read", + result: { content: [{ type: "text", text: "done" }] }, + isError: false, + }, + sessionContext, + ); + } + if (mode === "fatal") { + // The successor is the actual consuming run. A fatal transport response + // cannot release its accepted image until that exact run terminates. + socket.send( + JSON.stringify({ + type: "control_request", + id: "steered-abort", + operation: "turn.abort", + input: { mode: "terminal" }, + idempotencyKey: "steered-abort", + }), + ); + await waitFor( + () => frames.some(frame => frame.type === "agent_failed" && frame.commandId === correlation.commandId), + "steered fatal closure", + ); + expect(reserved).toBe(1); + } + const successorEnd = { + type: "agent_end" as const, + messages: [{ role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Image completed" }] }], + }; + trustedOwners.set(successorEnd, { resourceRunId: "successor-run", domain: successorDomain }); + await handlers.get("agent_end")?.(successorEnd, sessionContext); + expect(reserved).toBe(0); + if (mode === "natural" || mode === "queued-deadline" || mode === "joined-progress") { + expect(await queryResult()).toMatchObject({ + ok: true, + result: { status: "terminal_ok", outcome: { kind: "stopped", reason: "end_turn" } }, + }); + await handlers.get("agent_end")?.(successorEnd, sessionContext); + expect( + frames.filter(frame => frame.type === "agent_end" && frame.commandId === correlation.commandId), + ).toHaveLength(1); + } + await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext); + } finally { + preflight.resolve(); + releaseQueueAbort?.(); + heldCommit?.release(); + heldCommit?.restore(); + failedCommit?.restore(); + redeemSpy.mockRestore(); + warnSpy.mockRestore(); + } +}, 60_000); + +test("queued image controls cannot recreate leases after their connection closes", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-sdk-image-disconnect-")); dirs.push(cwd); - const sessionId = `sdk-prompt-images-${Date.now()}`; + const sessionId = `sdk-image-disconnect-${Date.now()}`; const sent: CapturedSendCall[] = []; const sessionContext = context(cwd, sessionId); - const handlers = start(sessionContext, undefined, (...args) => { - captureInternalSend(sent, args[0], args[1]); - }); + const handlers = start(sessionContext, undefined, (...args) => captureInternalSend(sent, args[0], args[1])); const endpointFile = path.join(cwd, ".gjc", "state", "sdk", `${sessionId}.json`); await waitFor(() => fs.existsSync(endpointFile), "SDK endpoint"); const endpoint = JSON.parse(fs.readFileSync(endpointFile, "utf8")) as { url: string; token: string }; - const frames: Record[] = []; - const socket = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`); - sockets.push(socket); - socket.addEventListener("message", event => frames.push(JSON.parse(String(event.data)))); - await new Promise((resolve, reject) => { - socket.addEventListener("open", () => resolve(), { once: true }); - socket.addEventListener("error", () => reject(new Error("WS error")), { once: true }); - }); - - const prompt = async (requestId: string, input: Record) => { - socket.send( - JSON.stringify({ - type: "control_command", - sessionId, - token: endpoint.token, - requestId, - command: { type: "control_request", id: requestId, operation: "turn.prompt", input }, - }), - ); - await waitFor( - () => frames.some(frame => frame.type === "control_command_result" && frame.requestId === requestId), - `${requestId} response`, - ); - }; - - await prompt("text-and-images", { - text: "Compare these screenshots.", - images: [{ data: "cG5nLWJ5dGVz", mimeType: "image/png" }, { data: "ZGVmYXVsdC1taW1l" }], + const stalled = Promise.withResolvers(); + const release = Promise.withResolvers(); + const originalDisconnect = PromptImageUploadStore.prototype.disconnect; + const originalBegin = PromptImageUploadStore.prototype.begin; + const beginOutcomes: Array<{ owner: string | undefined; code: string }> = []; + const beginSpy = spyOn(PromptImageUploadStore.prototype, "begin").mockImplementation(function ( + this: PromptImageUploadStore, + owner, + value, + ) { + try { + const result = originalBegin.call(this, owner, value); + beginOutcomes.push({ owner, code: "ok" }); + return result; + } catch (error) { + beginOutcomes.push({ owner, code: (error as { code?: string }).code ?? "internal" }); + throw error; + } }); - await handlers.get("agent_start")?.({ type: "agent_start" }, sessionContext); - await handlers.get("agent_end")?.({ type: "agent_end" }, sessionContext); - await prompt("images-only", { - text: "", - images: [{ data: "d2VicC1ieXRlcw", mimeType: "image/webp" }], + const appendSpy = spyOn(PromptImageUploadStore.prototype, "append"); + const disconnectSpy = spyOn(PromptImageUploadStore.prototype, "disconnect").mockImplementation(function ( + this: PromptImageUploadStore, + connectionId, + ) { + originalDisconnect.call(this, connectionId); + }); + const originalFinish = PromptImageUploadStore.prototype.finish; + let firstFinish = true; + const finishSpy = spyOn(PromptImageUploadStore.prototype, "finish").mockImplementation(async function ( + this: PromptImageUploadStore, + owner, + value, + ) { + if (!firstFinish) return originalFinish.call(this, owner, value); + firstFinish = false; + stalled.resolve(); + await release.promise; + return { id: (value as { id: string }).id, byteLength: 1, sha256: "0".repeat(64), mimeType: "image/png" }; }); - - expect(sent).toEqual([ - [ - [ - { type: "text", text: "Compare these screenshots." }, - { type: "image", data: "cG5nLWJ5dGVz", mimeType: "image/png" }, - { type: "image", data: "ZGVmYXVsdC1taW1l", mimeType: "image/jpeg" }, - ], - { - preflightSignal: expect.any(AbortSignal), - onQueuedPromoted: expect.any(Function), - onDispatchDisposition: expect.any(Function), - }, - ], - [ - [{ type: "image", data: "d2VicC1ieXRlcw", mimeType: "image/webp" }], - { - preflightSignal: expect.any(AbortSignal), - onQueuedPromoted: expect.any(Function), - onDispatchDisposition: expect.any(Function), - }, - ], - ]); + try { + const open = async (): Promise<{ socket: WebSocket; frames: Record[] }> => { + const frames: Record[] = []; + const socket = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`); + sockets.push(socket); + socket.addEventListener("message", event => frames.push(JSON.parse(String(event.data)))); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("WS error")), { once: true }); + }); + return { socket, frames }; + }; + const disconnected = await open(); + const healthy = await open(); + await waitFor(() => disconnected.frames.some(frame => frame.type === "hello"), "disconnected connection ID"); + await waitFor(() => healthy.frames.some(frame => frame.type === "hello"), "healthy connection ID"); + const disconnectedId = disconnected.frames.find(frame => frame.type === "hello")!.connectionId; + const healthyId = healthy.frames.find(frame => frame.type === "hello")!.connectionId; + expect(disconnectedId).not.toBe(healthyId); + const callsFrom = (spy: { mock: { calls: readonly (readonly unknown[])[] } }, owner: unknown) => + spy.mock.calls.filter(([connectionId]) => connectionId === owner).length; + const send = (socket: WebSocket, id: string, operation: string, input: Record) => + socket.send(JSON.stringify({ type: "control_request", id, operation, input })); + const descriptor = { mimeType: "image/png", byteLength: 1, sha256: "0".repeat(64) }; + send(disconnected.socket, "initial", "turn.image.begin", descriptor); + await waitFor(() => disconnected.frames.some(frame => frame.id === "initial"), "initial image lease"); + const initial = disconnected.frames.find(frame => frame.id === "initial") as { result: { id: string } }; + send(disconnected.socket, "held-finish", "turn.image.finish", { id: initial.result.id }); + await stalled.promise; + // These frames enter the ordered dispatcher while finish holds its queue. + send(disconnected.socket, "queued-append", "turn.image.append", { + id: initial.result.id, + sequence: 0, + data: "AA==", + }); + send(disconnected.socket, "queued-finish", "turn.image.finish", { id: initial.result.id }); + send(disconnected.socket, "queued-begin", "turn.image.begin", descriptor); + disconnected.socket.send(JSON.stringify({ type: "query_request", id: "received", query: "not.real" })); + await waitFor(() => disconnected.frames.some(frame => frame.id === "received"), "queued frames received"); + // The query is a same-socket receipt barrier, not part of the ordered control chain. + // The held finish must still keep every later image operation from executing. + expect(callsFrom(beginSpy, disconnectedId)).toBe(1); + expect(callsFrom(appendSpy, disconnectedId)).toBe(0); + expect(callsFrom(finishSpy, disconnectedId)).toBe(1); + send(healthy.socket, "live-prompt", "turn.prompt", { text: "Keep this prompt" }); + const closed = new Promise(resolve => + disconnected.socket.addEventListener("close", () => resolve(), { once: true }), + ); + disconnected.socket.close(); + await closed; + await waitFor(() => callsFrom(disconnectSpy, disconnectedId) === 1, "image lease disconnect sweep"); + expect(callsFrom(beginSpy, disconnectedId)).toBe(1); + expect(callsFrom(appendSpy, disconnectedId)).toBe(0); + release.resolve(); + await waitFor(() => healthy.frames.some(frame => frame.id === "live-prompt"), "queued live prompt"); + expect(healthy.frames.find(frame => frame.id === "live-prompt")).toMatchObject({ ok: true }); + expect(sent).toHaveLength(1); + const disconnectedBegins = beginOutcomes.filter(outcome => outcome.owner === disconnectedId); + expect(disconnectedBegins.map(outcome => outcome.code)).toEqual(["ok", "resource_gone"]); + send(healthy.socket, "live-begin", "turn.image.begin", descriptor); + await waitFor(() => healthy.frames.some(frame => frame.id === "live-begin"), "live image control"); + expect(healthy.frames.find(frame => frame.id === "live-begin")).toMatchObject({ ok: true }); + expect(callsFrom(beginSpy, healthyId)).toBe(1); + expect(beginOutcomes.filter(outcome => outcome.owner === healthyId).map(outcome => outcome.code)).toEqual(["ok"]); + // All 16 upload slots remain available: the queued begin cannot leave a + // lease behind after the disconnect sweep, even without a response socket. + for (let index = 1; index < 16; index++) { + const id = `live-begin-${index}`; + send(healthy.socket, id, "turn.image.begin", descriptor); + await waitFor(() => healthy.frames.some(frame => frame.id === id), `${id} image control`); + expect(healthy.frames.find(frame => frame.id === id)).toMatchObject({ ok: true }); + } + expect(callsFrom(beginSpy, healthyId)).toBe(16); + await handlers.get("session_shutdown")?.({ type: "session_shutdown" }, sessionContext); + } finally { + release.resolve(); + finishSpy.mockRestore(); + disconnectSpy.mockRestore(); + appendSpy.mockRestore(); + beginSpy.mockRestore(); + } }); test.each([ @@ -2891,6 +3936,7 @@ test("SDK host text-only stop/restart isolates late predecessor progress and ter abortHandles.push(handle); ledger.seal(handle); } + // Retired handles return their real retained settlement, not a root abort. return await ledger.waitForSettlement(handle, { graceMs: 0 }); }, }; @@ -3117,6 +4163,11 @@ test("SDK host text-only stop/restart isolates late predecessor progress and ter }, }); expect(abortHandles).toEqual([...predecessorAborts, "successor-deadline-run"]); + expect(abortAttempts).toEqual( + expect.arrayContaining([ + { handle: "successor-deadline-run", activeHandle: "successor-deadline-run", hasLiveDomain: true }, + ]), + ); expect(abortAttempts.some(attempt => attempt.hasLiveDomain && attempt.handle !== attempt.activeHandle)).toBe( false, ); diff --git a/packages/coding-agent/test/sdk-operation-inventory.test.ts b/packages/coding-agent/test/sdk-operation-inventory.test.ts index e44bd0b30ec..4a3a4428ee0 100644 --- a/packages/coding-agent/test/sdk-operation-inventory.test.ts +++ b/packages/coding-agent/test/sdk-operation-inventory.test.ts @@ -40,7 +40,7 @@ afterEach(async () => { describe("SDK operation inventory", () => { it("has complete typed operation and adapter coverage", () => { - expect(OPERATIONS.filter(operation => operation.kind === "control")).toHaveLength(53); + expect(OPERATIONS.filter(operation => operation.kind === "control")).toHaveLength(57); expect(OPERATIONS.filter(operation => operation.kind === "global")).toHaveLength(10); expect(OPERATIONS.filter(operation => operation.kind === "query")).toHaveLength(30); expect(OPERATIONS.filter(operation => operation.kind === "reverse")).toHaveLength(6); diff --git a/packages/coding-agent/test/sdk-operation-matrix.test.ts b/packages/coding-agent/test/sdk-operation-matrix.test.ts index 70cf3d8ce33..0f7755530a8 100644 --- a/packages/coding-agent/test/sdk-operation-matrix.test.ts +++ b/packages/coding-agent/test/sdk-operation-matrix.test.ts @@ -166,7 +166,7 @@ describe("SDK operation matrix", () => { }); it("keeps control errors, query continuity, counts, and the stage-05 adapter partition explicit", () => { - expect(OPERATIONS.filter(operation => operation.kind === "control")).toHaveLength(53); + expect(OPERATIONS.filter(operation => operation.kind === "control")).toHaveLength(57); expect(OPERATIONS.filter(operation => operation.kind === "global")).toHaveLength(10); expect(OPERATIONS.filter(operation => operation.kind === "query")).toHaveLength(30); expect(OPERATIONS.filter(operation => operation.kind === "reverse")).toHaveLength(6); diff --git a/packages/coding-agent/test/sdk-prompt-image-upload.test.ts b/packages/coding-agent/test/sdk-prompt-image-upload.test.ts new file mode 100644 index 00000000000..667e2d54f93 --- /dev/null +++ b/packages/coding-agent/test/sdk-prompt-image-upload.test.ts @@ -0,0 +1,968 @@ +import { expect, test, vi } from "bun:test"; +import { createHash } from "node:crypto"; +import { deflateSync } from "node:zlib"; +import { PromptImageUploadStore } from "../src/sdk/host/prompt-image-upload"; + +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const MAX_CHUNK_BYTES = 96 * 1024; +// Standalone tests deliberately allow synthetic owners; hosts bind this to live sockets. +const alwaysConnected = (_owner: string): boolean => true; + +function pngChunk(type: string, data = Buffer.alloc(0)): Buffer { + const name = Buffer.from(type, "ascii"); + const crcInput = Buffer.concat([name, data]); + let crc = 0xffffffff; + for (const byte of crcInput) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const checksum = Buffer.alloc(4); + checksum.writeUInt32BE((crc ^ 0xffffffff) >>> 0); + return Buffer.concat([length, name, data, checksum]); +} + +function tinyPng(seed = 17): Buffer { + const header = Buffer.alloc(13); + header.writeUInt32BE(1, 0); + header.writeUInt32BE(1, 4); + header[8] = 8; + header[9] = 2; + return Buffer.concat([ + PNG_SIGNATURE, + pngChunk("IHDR", header), + pngChunk("IDAT", deflateSync(Buffer.from([0, seed & 255, (seed + 1) & 255, (seed + 2) & 255]))), + pngChunk("IEND"), + ]); +} + +/** Uncompressed scanlines with varied RGB pixels: the encoded image itself exceeds one SDK frame. */ +function originalLargePng(seed = 0x12345678): Buffer { + const width = 400; + const height = 300; + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 2; + const pixels = Buffer.alloc(height * (1 + width * 3)); + let state = seed; + for (let row = 0; row < height; row++) { + const offset = row * (1 + width * 3); + pixels[offset] = 0; + for (let column = 1; column <= width * 3; column++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + pixels[offset + column] = state & 255; + } + } + return Buffer.concat([ + PNG_SIGNATURE, + pngChunk("IHDR", header), + pngChunk("IDAT", deflateSync(pixels)), + pngChunk("IEND"), + ]); +} + +const digest = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex"); +const descriptor = (bytes: Buffer) => ({ mimeType: "image/png", byteLength: bytes.length, sha256: digest(bytes) }); + +function upload(store: PromptImageUploadStore, owner: string, bytes: Buffer, batchId?: string): string { + const input = descriptor(bytes); + const { id } = store.begin(owner, batchId === undefined ? input : { ...input, batchId }); + let sequence = 0; + for (let offset = 0; offset < bytes.length; offset += 96 * 1024) { + const chunk = bytes.subarray(offset, offset + 96 * 1024); + expect(store.append(owner, { id, sequence, data: chunk.toString("base64") })).toEqual({ + id, + nextSequence: ++sequence, + receivedBytes: offset + chunk.length, + }); + } + return id; +} + +async function stage(store: PromptImageUploadStore, owner: string, bytes: Buffer, batchId?: string): Promise { + const id = upload(store, owner, bytes, batchId); + await store.finish(owner, { id }); + return id; +} + +function fillNearLimit(store: PromptImageUploadStore): string[] { + const fullChunk = Buffer.alloc(MAX_CHUNK_BYTES).toString("base64"); + const finalChunk = Buffer.alloc(MAX_CHUNK_BYTES - 1).toString("base64"); + const chunkCounts = [171, 171, 170, 170]; + const ids: string[] = []; + for (const chunkCount of chunkCounts) { + const { id } = store.begin("sender", { + mimeType: "image/png", + byteLength: 20 * 1024 * 1024, + sha256: "0".repeat(64), + }); + ids.push(id); + for (let sequence = 0; sequence < chunkCount - 1; sequence++) + store.append("sender", { id, sequence, data: fullChunk }); + store.append("sender", { id, sequence: chunkCount - 1, data: finalChunk }); + } + return ids; +} + +test("a valid original image larger than 256 KiB survives chunk upload and host redemption byte for byte", async () => { + const bytes = originalLargePng(); + expect(bytes.length).toBeGreaterThan(256 * 1024); + const store = new PromptImageUploadStore(alwaysConnected); + try { + const id = await stage(store, "sender", bytes); + const accepted = store.redeem("sender", [{ id }]); + try { + expect(accepted.images).toHaveLength(1); + expect(accepted.images[0]?.mimeType).toBe("image/png"); + expect(Buffer.from(accepted.images[0]!.data, "base64").toString("hex")).toBe(bytes.toString("hex")); + expect(() => store.redeem("sender", [{ id }])).toThrow(expect.objectContaining({ code: "resource_gone" })); + } finally { + accepted.release(); + } + } finally { + store.close(); + } +}); + +test("100000 one-byte fragments coalesce into bounded allocations and preserve exact bytes and final boundaries", async () => { + const header = Buffer.alloc(13); + header.writeUInt32BE(1, 0); + header.writeUInt32BE(1, 4); + header[8] = 8; + header[9] = 2; + const core = Buffer.concat([ + PNG_SIGNATURE, + pngChunk("IHDR", header), + pngChunk("IDAT", deflateSync(Buffer.from([0, 17, 34, 51]))), + pngChunk("IEND"), + ]); + const byteLength = 100_000; + const bytes = Buffer.concat([ + core.subarray(0, -12), + pngChunk("ruSt", Buffer.alloc(byteLength - core.length - 12)), + core.subarray(-12), + ]); + expect(bytes).toHaveLength(byteLength); + const encodedByte = Array.from({ length: 256 }, (_, value) => Buffer.from([value]).toString("base64")); + const store = new PromptImageUploadStore(alwaysConnected); + const allocatedSizes: number[] = []; + const allocate = Buffer.allocUnsafeSlow; + const allocations = vi.spyOn(Buffer, "allocUnsafeSlow").mockImplementation(size => { + allocatedSizes.push(size); + return allocate(size); + }); + try { + const { id } = store.begin("sender", descriptor(bytes)); + let result = { id, nextSequence: 0, receivedBytes: 0 }; + for (let offset = 0; offset < bytes.length; offset++) + result = store.append("sender", { + id, + sequence: offset, + data: encodedByte[bytes[offset]!]!, + }); + expect(allocatedSizes).toEqual([MAX_CHUNK_BYTES, byteLength - MAX_CHUNK_BYTES]); + allocations.mockRestore(); + expect(result).toEqual({ id, nextSequence: bytes.length, receivedBytes: bytes.length }); + expect(() => store.append("sender", { id, sequence: bytes.length, data: "AQ==" })).toThrow( + expect.objectContaining({ code: "invalid_input" }), + ); + await store.finish("sender", { id }); + const accepted = store.redeem("sender", [{ id }]); + try { + expect(accepted.images[0]?.mimeType).toBe("image/png"); + expect(Buffer.from(accepted.images[0]!.data, "base64").equals(bytes)).toBe(true); + } finally { + accepted.release(); + } + } finally { + allocations.mockRestore(); + store.close(); + } +}); + +test("coalesced allocation quotas recover on discard, disconnect and expiry", () => { + const crossingChunk = Buffer.from([1, 2]).toString("base64"); + + const discardedStore = new PromptImageUploadStore(alwaysConnected); + try { + const ids = fillNearLimit(discardedStore); + expect(() => discardedStore.append("sender", { id: ids[3]!, sequence: 170, data: crossingChunk })).toThrow( + expect.objectContaining({ code: "busy" }), + ); + discardedStore.discard("sender", { id: ids[0] }); + expect(discardedStore.append("sender", { id: ids[3]!, sequence: 170, data: crossingChunk })).toMatchObject({ + nextSequence: 171, + }); + } finally { + discardedStore.close(); + } + Bun.gc(true); + + const disconnectedStore = new PromptImageUploadStore(alwaysConnected); + try { + const ids = fillNearLimit(disconnectedStore); + expect(() => disconnectedStore.append("sender", { id: ids[3]!, sequence: 170, data: crossingChunk })).toThrow( + expect.objectContaining({ code: "busy" }), + ); + disconnectedStore.disconnect("sender"); + const replacement = disconnectedStore.begin("sender", { + mimeType: "image/png", + byteLength: 20 * 1024 * 1024, + sha256: "0".repeat(64), + }); + const fullChunk = Buffer.alloc(MAX_CHUNK_BYTES).toString("base64"); + expect(disconnectedStore.append("sender", { id: replacement.id, sequence: 0, data: fullChunk })).toMatchObject({ + receivedBytes: MAX_CHUNK_BYTES, + }); + } finally { + disconnectedStore.close(); + } + Bun.gc(true); + + const expiredStore = new PromptImageUploadStore(alwaysConnected); + try { + vi.useFakeTimers(); + const ids = fillNearLimit(expiredStore); + expect(() => expiredStore.append("sender", { id: ids[3]!, sequence: 170, data: crossingChunk })).toThrow( + expect.objectContaining({ code: "busy" }), + ); + vi.advanceTimersByTime(120_000); + const replacement = expiredStore.begin("sender", { + mimeType: "image/png", + byteLength: 20 * 1024 * 1024, + sha256: "0".repeat(64), + }); + const fullChunk = Buffer.alloc(MAX_CHUNK_BYTES).toString("base64"); + expect(expiredStore.append("sender", { id: replacement.id, sequence: 0, data: fullChunk })).toMatchObject({ + receivedBytes: MAX_CHUNK_BYTES, + }); + } finally { + vi.useRealTimers(); + expiredStore.close(); + } +}); + +test("concurrent finishes validate one lease only once and reject appends during finalization", async () => { + const bytes = originalLargePng(); + const store = new PromptImageUploadStore(alwaysConnected); + try { + const { id } = store.begin("sender", descriptor(bytes)); + for (let offset = 0, sequence = 0; offset < bytes.length; offset += 96 * 1024, sequence++) + store.append("sender", { + id, + sequence, + data: bytes.subarray(offset, offset + 96 * 1024).toString("base64"), + }); + const attempts = Array.from({ length: 8 }, () => store.finish("sender", { id })); + const settled = Promise.allSettled(attempts); + expect(() => store.append("sender", { id, sequence: 4, data: "AA==" })).toThrow( + expect.objectContaining({ code: "invalid_input" }), + ); + const outcomes = await settled; + expect(outcomes.filter(outcome => outcome.status === "fulfilled")).toHaveLength(1); + for (const outcome of outcomes) + if (outcome.status === "rejected") expect(outcome.reason).toMatchObject({ code: "invalid_input" }); + const accepted = store.redeem("sender", [{ id }]); + try { + expect(Buffer.from(accepted.images[0]!.data, "base64").equals(bytes)).toBe(true); + } finally { + accepted.release(); + } + } finally { + store.close(); + } +}); + +test("distinct sessions share one process-wide decode slot and recover it after finalization", async () => { + const bytes = originalLargePng(); + const firstStore = new PromptImageUploadStore(alwaysConnected); + const secondStore = new PromptImageUploadStore(alwaysConnected); + try { + const first = upload(firstStore, "sender", bytes); + const second = upload(secondStore, "sender", bytes); + const finishing = firstStore.finish("sender", { id: first }); + const competing = secondStore.finish("sender", { id: second }); + await expect(competing).rejects.toMatchObject({ code: "busy" }); + await finishing; + await secondStore.finish("sender", { id: second }); + const firstAccepted = firstStore.redeem("sender", [{ id: first }]); + const secondAccepted = secondStore.redeem("sender", [{ id: second }]); + try { + expect(Buffer.from(firstAccepted.images[0]!.data, "base64").equals(bytes)).toBe(true); + expect(Buffer.from(secondAccepted.images[0]!.data, "base64").equals(bytes)).toBe(true); + } finally { + firstAccepted.release(); + secondAccepted.release(); + } + } finally { + firstStore.close(); + secondStore.close(); + } +}); + +test("discard during decoding retains the shared slot until its in-flight finish settles", async () => { + const bytes = originalLargePng(); + const firstStore = new PromptImageUploadStore(alwaysConnected); + const secondStore = new PromptImageUploadStore(alwaysConnected); + try { + const first = upload(firstStore, "sender", bytes); + const second = upload(secondStore, "sender", bytes); + const finishing = firstStore.finish("sender", { id: first }); + firstStore.discard("sender", { id: first }); + const competing = secondStore.finish("sender", { id: second }); + await expect(competing).rejects.toMatchObject({ code: "busy" }); + await expect(finishing).rejects.toMatchObject({ code: "resource_gone" }); + await secondStore.finish("sender", { id: second }); + const accepted = secondStore.redeem("sender", [{ id: second }]); + try { + expect(Buffer.from(accepted.images[0]!.data, "base64").equals(bytes)).toBe(true); + } finally { + accepted.release(); + } + } finally { + firstStore.close(); + secondStore.close(); + } +}); + +test("upload chunks reject out-of-order, duplicate, noncanonical, cross-connection and bad-digest inputs", async () => { + const bytes = originalLargePng(); + const store = new PromptImageUploadStore(alwaysConnected); + try { + const { id } = store.begin("sender", descriptor(bytes)); + const chunk = bytes.subarray(0, 96 * 1024).toString("base64"); + expect(() => store.append("sender", { id, sequence: 1, data: chunk })).toThrow( + expect.objectContaining({ code: "invalid_input" }), + ); + expect(() => store.append("other", { id, sequence: 0, data: chunk })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + expect(() => store.append("sender", { id, sequence: 0, data: `${chunk}\n` })).toThrow( + expect.objectContaining({ code: "invalid_input" }), + ); + store.append("sender", { id, sequence: 0, data: chunk }); + expect(() => store.append("sender", { id, sequence: 0, data: chunk })).toThrow( + expect.objectContaining({ code: "invalid_input" }), + ); + await expect(store.finish("sender", { id })).rejects.toMatchObject({ code: "invalid_input" }); + for (let offset = 96 * 1024, sequence = 1; offset < bytes.length; offset += 96 * 1024, sequence++) + store.append("sender", { id, sequence, data: bytes.subarray(offset, offset + 96 * 1024).toString("base64") }); + await expect(store.finish("other", { id })).rejects.toMatchObject({ code: "resource_gone" }); + await store.finish("sender", { id }); + expect(() => + store.append("sender", { id, sequence: Math.ceil(bytes.length / (96 * 1024)), data: chunk }), + ).toThrow(expect.objectContaining({ code: "invalid_input" })); + + const wrong = store.begin("sender", { ...descriptor(bytes), sha256: "0".repeat(64) }); + for (let offset = 0, sequence = 0; offset < bytes.length; offset += 96 * 1024, sequence++) + store.append("sender", { + id: wrong.id, + sequence, + data: bytes.subarray(offset, offset + 96 * 1024).toString("base64"), + }); + await expect(store.finish("sender", { id: wrong.id })).rejects.toMatchObject({ code: "invalid_input" }); + expect(() => store.redeem("sender", [{ id: wrong.id }])).toThrow( + expect.objectContaining({ code: "invalid_input" }), + ); + } finally { + store.close(); + } +}); + +test("redemption is atomic, ordered and connection-owned; discard, disconnect and close expire leases", async () => { + const bytes = originalLargePng(); + const other = originalLargePng(0x87654321); + const store = new PromptImageUploadStore(alwaysConnected); + try { + const first = await stage(store, "sender", bytes); + const second = await stage(store, "sender", other); + expect(() => store.redeem("other", [{ id: first }])).toThrow(expect.objectContaining({ code: "resource_gone" })); + expect(() => store.redeem("sender", [{ id: first }, { id: first }])).toThrow( + expect.objectContaining({ code: "invalid_input" }), + ); + expect(() => store.redeem("sender", [{ id: first }, { id: "missing" }])).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + const accepted = store.redeem("sender", [{ id: second }, { id: first }]); + try { + expect(accepted.images.map(image => Buffer.from(image.data, "base64").toString("hex"))).toEqual([ + other.toString("hex"), + bytes.toString("hex"), + ]); + } finally { + accepted.release(); + } + for (const id of [first, second]) + expect(() => store.redeem("sender", [{ id }])).toThrow(expect.objectContaining({ code: "resource_gone" })); + const discarded = store.begin("sender", descriptor(bytes)); + expect(store.discard("sender", { id: discarded.id })).toEqual({ discarded: true }); + expect(() => store.append("sender", { id: discarded.id, sequence: 0, data: bytes.toString("base64") })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + const disconnected = store.begin("sender", descriptor(bytes)); + store.disconnect("sender"); + expect(() => store.redeem("sender", [{ id: disconnected.id }])).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + expect(() => store.append("sender", { id: disconnected.id, sequence: 0, data: "AA==" })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + await expect(store.finish("sender", { id: disconnected.id })).rejects.toMatchObject({ code: "resource_gone" }); + expect(() => store.discard("sender", { id: disconnected.id })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + const closed = store.begin("sender", descriptor(bytes)); + store.close(); + await expect(store.finish("sender", { id: closed.id })).rejects.toMatchObject({ + code: "resource_gone", + }); + } finally { + store.close(); + } +}); + +test("live-connection gate rejects queued image mutations after the disconnect sweep", async () => { + const live = new Set(["sender", "other"]); + const store = new PromptImageUploadStore(owner => live.has(owner)); + const input = { mimeType: "image/png", byteLength: 1, sha256: "0".repeat(64) }; + try { + const pending = store.begin("sender", input); + store.append("sender", { id: pending.id, sequence: 0, data: "AA==" }); + live.delete("sender"); + store.disconnect("sender"); + expect(() => store.begin("sender", input)).toThrow(expect.objectContaining({ code: "resource_gone" })); + expect(() => store.append("sender", { id: pending.id, sequence: 1, data: "AA==" })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + await expect(store.finish("sender", { id: pending.id })).rejects.toMatchObject({ code: "resource_gone" }); + expect(() => store.discard("sender", { id: pending.id })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + expect(() => store.redeem("sender", [{ id: pending.id }])).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + const healthy = store.begin("other", input); + expect(store.append("other", { id: healthy.id, sequence: 0, data: "AA==" })).toMatchObject({ + receivedBytes: 1, + }); + } finally { + store.close(); + } +}); + +test.each([ + 119_999, 120_000, 120_001, +])("redemption enforces elapsed inactivity at %d ms without timer delivery", async elapsed => { + let now = 1_700_000_000_000; + const clock = vi.spyOn(Date, "now").mockImplementation(() => now); + const store = new PromptImageUploadStore(alwaysConnected); + const bytes = tinyPng(); + try { + const id = await stage(store, "sender", bytes); + now += elapsed; + if (elapsed < 120_000) { + const accepted = store.redeem("sender", [{ id }]); + try { + expect(Buffer.from(accepted.images[0]!.data, "base64").equals(bytes)).toBe(true); + } finally { + accepted.release(); + } + } else { + expect(() => store.redeem("sender", [{ id }])).toThrow(expect.objectContaining({ code: "resource_gone" })); + } + } finally { + store.close(); + clock.mockRestore(); + } +}); + +test.each([ + 119_999, 120_000, 120_001, +])("successful batch progress never revives elapsed-expired peers at %d ms", async elapsed => { + let now = 1_700_000_000_000; + const clock = vi.spyOn(Date, "now").mockImplementation(() => now); + const store = new PromptImageUploadStore(alwaysConnected); + const bytes = tinyPng(); + try { + const peer = await stage(store, "sender", bytes, "same-batch"); + now += elapsed; + const fresh = store.begin("sender", { ...descriptor(bytes), batchId: "same-batch" }); + now += 1; + if (elapsed < 120_000) { + const accepted = store.redeem("sender", [{ id: peer }]); + accepted.release(); + } else { + expect(() => store.redeem("sender", [{ id: peer }])).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + } + expect(store.append("sender", { id: fresh.id, sequence: 0, data: bytes.toString("base64") })).toMatchObject({ + receivedBytes: bytes.length, + }); + await store.finish("sender", { id: fresh.id }); + const accepted = store.redeem("sender", [{ id: fresh.id }]); + try { + expect(Buffer.from(accepted.images[0]!.data, "base64").equals(bytes)).toBe(true); + } finally { + accepted.release(); + } + } finally { + store.close(); + clock.mockRestore(); + } +}); + +test.each([ + "append", + "finish", + "discard", +] as const)("%s rejects elapsed expiry without timer delivery or an admission sweep", async operation => { + let now = 1_700_000_000_000; + const clock = vi.spyOn(Date, "now").mockImplementation(() => now); + const store = new PromptImageUploadStore(alwaysConnected); + const bytes = tinyPng(); + try { + const { id } = store.begin("sender", descriptor(bytes)); + if (operation !== "append") store.append("sender", { id, sequence: 0, data: bytes.toString("base64") }); + now += 120_000; + if (operation === "append") { + expect(() => store.append("sender", { id, sequence: 0, data: bytes.toString("base64") })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + } else if (operation === "finish") { + await expect(store.finish("sender", { id })).rejects.toMatchObject({ code: "resource_gone" }); + } else { + expect(() => store.discard("sender", { id })).toThrow(expect.objectContaining({ code: "resource_gone" })); + } + } finally { + store.close(); + clock.mockRestore(); + } +}); + +test("batch renewal cannot revive a peer that expires between admission sweep and renewal", async () => { + let now = 1_700_000_000_000; + const clock = vi.spyOn(Date, "now").mockImplementation(() => now); + const store = new PromptImageUploadStore(alwaysConnected); + const bytes = tinyPng(); + try { + const peer = await stage(store, "sender", bytes, "same-batch"); + now += 119_999; + clock.mockImplementationOnce(() => now++); + const fresh = store.begin("sender", { ...descriptor(bytes), batchId: "same-batch" }); + expect(() => store.redeem("sender", [{ id: peer }])).toThrow(expect.objectContaining({ code: "resource_gone" })); + store.append("sender", { id: fresh.id, sequence: 0, data: bytes.toString("base64") }); + await store.finish("sender", { id: fresh.id }); + const accepted = store.redeem("sender", [{ id: fresh.id }]); + try { + expect(Buffer.from(accepted.images[0]!.data, "base64").equals(bytes)).toBe(true); + } finally { + accepted.release(); + } + } finally { + store.close(); + clock.mockRestore(); + } +}); + +test("elapsed expiry frees upload slots before timer delivery", async () => { + let now = 1_700_000_000_000; + const clock = vi.spyOn(Date, "now").mockImplementation(() => now); + const store = new PromptImageUploadStore(alwaysConnected); + const bytes = tinyPng(); + try { + const ids = Array.from({ length: 16 }, () => store.begin("sender", descriptor(bytes)).id); + now += 120_000; + const fresh = store.begin("sender", descriptor(bytes)); + expect(() => store.append("sender", { id: ids[0], sequence: 0, data: bytes.toString("base64") })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + await expect(store.finish("sender", { id: ids[1] })).rejects.toMatchObject({ code: "resource_gone" }); + expect(() => store.discard("sender", { id: ids[2] })).toThrow(expect.objectContaining({ code: "resource_gone" })); + expect(store.append("sender", { id: fresh.id, sequence: 0, data: bytes.toString("base64") })).toMatchObject({ + receivedBytes: bytes.length, + }); + } finally { + store.close(); + clock.mockRestore(); + } +}); + +test("finish crossing elapsed expiry fails closed and releases decoder reservations without timer delivery", async () => { + let now = 1_700_000_000_000; + const clock = vi.spyOn(Date, "now").mockImplementation(() => now); + const store = new PromptImageUploadStore(alwaysConnected); + const bytes = tinyPng(); + try { + const id = upload(store, "sender", bytes); + const finishing = store.finish("sender", { id }); + now += 120_000; + await expect(finishing).rejects.toMatchObject({ code: "resource_gone" }); + expect(() => store.redeem("sender", [{ id }])).toThrow(expect.objectContaining({ code: "resource_gone" })); + const next = await stage(store, "sender", bytes); + const accepted = store.redeem("sender", [{ id: next }]); + accepted.release(); + } finally { + store.close(); + clock.mockRestore(); + } +}); + +test("expired leases free capacity and a discard during decoding cannot resurrect an upload", async () => { + const bytes = originalLargePng(); + const store = new PromptImageUploadStore(alwaysConnected); + try { + vi.useFakeTimers(); + const expired = store.begin("sender", descriptor(bytes)); + store.append("sender", { id: expired.id, sequence: 0, data: bytes.subarray(0, 96 * 1024).toString("base64") }); + vi.advanceTimersByTime(120_000); + expect(() => store.append("sender", { id: expired.id, sequence: 1, data: "AA==" })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + vi.useRealTimers(); + + const pending = store.begin("sender", descriptor(bytes)); + for (let offset = 0, sequence = 0; offset < bytes.length; offset += 96 * 1024, sequence++) + store.append("sender", { + id: pending.id, + sequence, + data: bytes.subarray(offset, offset + 96 * 1024).toString("base64"), + }); + const finishing = store.finish("sender", { id: pending.id }); + expect(store.discard("sender", { id: pending.id })).toEqual({ discarded: true }); + await expect(finishing).rejects.toMatchObject({ code: "resource_gone" }); + expect(() => store.redeem("sender", [{ id: pending.id }])).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + const next = await stage(store, "sender", bytes); + const accepted = store.redeem("sender", [{ id: next }]); + try { + expect(Buffer.from(accepted.images[0]!.data, "base64").equals(bytes)).toBe(true); + } finally { + accepted.release(); + } + } finally { + vi.useRealTimers(); + store.close(); + } +}); + +test("batch IDs are nonempty strings bounded to 128 characters", () => { + const store = new PromptImageUploadStore(alwaysConnected); + const input = descriptor(tinyPng()); + try { + for (const batchId of ["", "x".repeat(129), 17, null, undefined]) + expect(() => store.begin("sender", { ...input, batchId })).toThrow( + expect.objectContaining({ code: "invalid_input" }), + ); + expect(store.begin("sender", { ...input, batchId: "x".repeat(128) })).toMatchObject({ nextSequence: 0 }); + expect(store.begin("sender", input)).toMatchObject({ nextSequence: 0 }); + } finally { + store.close(); + } +}); + +test("slow same-batch progress renews early-finished uploads for the full transfer", async () => { + const earlyBytes = tinyPng(11); + const slowBytes = originalLargePng(); + const store = new PromptImageUploadStore(alwaysConnected); + try { + vi.useFakeTimers(); + const early = await stage(store, "sender", earlyBytes, "request-ref"); + const slow = store.begin("sender", { ...descriptor(slowBytes), batchId: "request-ref" }); + const startedAt = Date.now(); + for (let offset = 0, sequence = 0; offset < slowBytes.length; offset += MAX_CHUNK_BYTES, sequence++) { + vi.advanceTimersByTime(60_000); + store.append("sender", { + id: slow.id, + sequence, + data: slowBytes.subarray(offset, offset + MAX_CHUNK_BYTES).toString("base64"), + }); + } + expect(Date.now() - startedAt).toBeGreaterThan(120_000); + await store.finish("sender", { id: slow.id }); + const accepted = store.redeem("sender", [{ id: early }, { id: slow.id }]); + try { + expect(accepted.images.map(image => Buffer.from(image.data, "base64").toString("hex"))).toEqual([ + earlyBytes.toString("hex"), + slowBytes.toString("hex"), + ]); + } finally { + accepted.release(); + } + } finally { + vi.useRealTimers(); + store.close(); + } +}); + +test("successful finish renews finished peers in the same batch", async () => { + const peerBytes = tinyPng(31); + const finishingBytes = tinyPng(73); + const store = new PromptImageUploadStore(alwaysConnected); + try { + vi.useFakeTimers(); + const peer = await stage(store, "sender", peerBytes, "finish-ref"); + const finishing = upload(store, "sender", finishingBytes, "finish-ref"); + vi.advanceTimersByTime(119_000); + await store.finish("sender", { id: finishing }); + vi.advanceTimersByTime(2_000); + const accepted = store.redeem("sender", [{ id: peer }]); + try { + expect(Buffer.from(accepted.images[0]!.data, "base64").equals(peerBytes)).toBe(true); + } finally { + accepted.release(); + } + } finally { + vi.useRealTimers(); + store.close(); + } +}); + +test("batch lease renewals are owner-scoped and unbatched progress renews only its entry", async () => { + const bytes = tinyPng(); + const store = new PromptImageUploadStore(alwaysConnected); + try { + vi.useFakeTimers(); + const sameBatch = await stage(store, "owner", bytes, "shared"); + const otherBatch = await stage(store, "owner", bytes, "different"); + const otherOwner = await stage(store, "other-owner", bytes, "shared"); + const unbatched = await stage(store, "owner", bytes); + const partial = { mimeType: "image/png", byteLength: 2, sha256: "0".repeat(64) }; + const unbatchedProgress = store.begin("owner", partial); + const unbatchedSibling = store.begin("owner", partial); + + vi.advanceTimersByTime(119_000); + const newBatchEntry = store.begin("owner", { ...partial, batchId: "shared" }); + store.append("owner", { id: unbatchedProgress.id, sequence: 0, data: "AQ==" }); + vi.advanceTimersByTime(2_000); + + const accepted = store.redeem("owner", [{ id: sameBatch }]); + accepted.release(); + for (const [owner, id] of [ + ["owner", otherBatch], + ["other-owner", otherOwner], + ["owner", unbatched], + ] as const) + expect(() => store.redeem(owner, [{ id }])).toThrow(expect.objectContaining({ code: "resource_gone" })); + expect(() => store.append("owner", { id: unbatchedSibling.id, sequence: 0, data: "AQ==" })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + expect(store.append("owner", { id: unbatchedProgress.id, sequence: 1, data: "Ag==" })).toMatchObject({ + receivedBytes: 2, + }); + expect(store.append("owner", { id: newBatchEntry.id, sequence: 0, data: "AQ==" })).toMatchObject({ + receivedBytes: 1, + }); + } finally { + vi.useRealTimers(); + store.close(); + } +}); + +test("invalid, incomplete, repeated and busy traffic cannot extend upload leases", async () => { + const bytes = tinyPng(); + const store = new PromptImageUploadStore(alwaysConnected); + const partial = { mimeType: "image/png", byteLength: 2, sha256: "0".repeat(64) }; + try { + vi.useFakeTimers(); + const incomplete = store.begin("incomplete-owner", partial); + const invalidBatch = await stage(store, "invalid-owner", bytes, "shared"); + const finished = await stage(store, "finished-owner", bytes, "repeat"); + const wrongDigest = store.begin("digest-owner", { + ...descriptor(bytes), + sha256: "0".repeat(64), + batchId: "failed-finish", + }); + store.append("digest-owner", { id: wrongDigest.id, sequence: 0, data: bytes.toString("base64") }); + const failedFinishPeer = await stage(store, "digest-owner", bytes, "failed-finish"); + const busyTarget = store.begin("busy-owner", { ...descriptor(bytes), batchId: "busy" }); + for (let index = 0; index < 10; index++) store.begin("busy-owner", descriptor(bytes)); + + vi.advanceTimersByTime(119_000); + expect(() => store.append("incomplete-owner", { id: incomplete.id, sequence: 1, data: "AQ==" })).toThrow( + expect.objectContaining({ code: "invalid_input" }), + ); + await expect(store.finish("incomplete-owner", { id: incomplete.id })).rejects.toMatchObject({ + code: "invalid_input", + }); + expect(() => store.begin("invalid-owner", { ...descriptor(bytes), batchId: "x".repeat(129) })).toThrow( + expect.objectContaining({ code: "invalid_input" }), + ); + await expect(store.finish("finished-owner", { id: finished })).rejects.toMatchObject({ + code: "invalid_input", + }); + await expect(store.finish("digest-owner", { id: wrongDigest.id })).rejects.toMatchObject({ + code: "invalid_input", + }); + expect(() => store.begin("busy-owner", { ...descriptor(bytes), batchId: "busy" })).toThrow( + expect.objectContaining({ code: "busy" }), + ); + vi.advanceTimersByTime(2_000); + + expect(() => store.append("incomplete-owner", { id: incomplete.id, sequence: 0, data: "AQ==" })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + for (const [owner, id] of [ + ["invalid-owner", invalidBatch], + ["finished-owner", finished], + ["digest-owner", failedFinishPeer], + ] as const) + expect(() => store.redeem(owner, [{ id }])).toThrow(expect.objectContaining({ code: "resource_gone" })); + expect(() => store.append("digest-owner", { id: wrongDigest.id, sequence: 1, data: "AQ==" })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + expect(() => store.append("busy-owner", { id: busyTarget.id, sequence: 0, data: "AQ==" })).toThrow( + expect.objectContaining({ code: "resource_gone" }), + ); + } finally { + vi.useRealTimers(); + store.close(); + } +}); + +test("pending uploads enforce the 64 MiB session budget and recover capacity on discard and disconnect", () => { + const store = new PromptImageUploadStore(alwaysConnected); + const capacity = 64 * 1024 * 1024; + const imageLength = capacity / 4; + const chunk = Buffer.alloc(96 * 1024).toString("base64"); + const tail = Buffer.alloc(64 * 1024).toString("base64"); + const ids: string[] = []; + try { + for (let image = 0; image < 4; image++) { + const { id } = store.begin("sender", { + mimeType: "image/png", + byteLength: imageLength, + sha256: "0".repeat(64), + }); + ids.push(id); + for (let sequence = 0; sequence < 170; sequence++) store.append("sender", { id, sequence, data: chunk }); + store.append("sender", { id, sequence: 170, data: tail }); + } + const next = store.begin("sender", { mimeType: "image/png", byteLength: 1, sha256: "0".repeat(64) }); + expect(() => store.append("sender", { id: next.id, sequence: 0, data: "AA==" })).toThrow( + expect.objectContaining({ code: "busy" }), + ); + expect(store.discard("sender", { id: ids[0] })).toEqual({ discarded: true }); + expect(store.append("sender", { id: next.id, sequence: 0, data: "AA==" })).toMatchObject({ receivedBytes: 1 }); + store.disconnect("sender"); + const resumed = store.begin("sender", { mimeType: "image/png", byteLength: 1, sha256: "0".repeat(64) }); + expect(store.append("sender", { id: resumed.id, sequence: 0, data: "AA==" })).toMatchObject({ + receivedBytes: 1, + }); + } finally { + store.close(); + } +}); + +test("accepted images enforce the 64 MiB session budget until their terminal release", async () => { + const original = originalLargePng(); + // A safe-to-copy ancillary chunk enlarges the source without changing decoded pixels. + const image = Buffer.concat([ + original.subarray(0, -12), + pngChunk("ruSt", Buffer.alloc(17 * 1024 * 1024)), + original.subarray(-12), + ]); + const store = new PromptImageUploadStore(alwaysConnected); + const releases: Array<() => void> = []; + try { + for (let index = 0; index < 3; index++) { + const id = await stage(store, "sender", image); + releases.push(store.redeem("sender", [{ id }]).release); + } + const pending = await stage(store, "sender", image); + expect(() => store.redeem("sender", [{ id: pending }])).toThrow(expect.objectContaining({ code: "busy" })); + releases[0]!(); + const recovered = store.redeem("sender", [{ id: pending }]); + expect(Buffer.from(recovered.images[0]!.data, "base64").equals(image)).toBe(true); + releases.push(recovered.release); + releases[0]!(); + } finally { + for (const release of releases) release(); + store.close(); + } +}, 60_000); + +test("accepted base64 copies and transient decoding share a process-wide budget across sessions", async () => { + const original = originalLargePng(); + const image = Buffer.concat([ + original.subarray(0, -12), + pngChunk("ruSt", Buffer.alloc(19 * 1024 * 1024)), + original.subarray(-12), + ]); + const stores = Array.from({ length: 6 }, () => new PromptImageUploadStore(alwaysConnected)); + const releases: Array<() => void> = []; + try { + for (const store of stores.slice(0, 5)) { + const id = await stage(store, "sender", image); + releases.push(store.redeem("sender", [{ id }]).release); + } + const pending = upload(stores[5]!, "sender", image); + await expect(stores[5]!.finish("sender", { id: pending })).rejects.toMatchObject({ code: "busy" }); + releases[0]!(); + await stores[5]!.finish("sender", { id: pending }); + const recovered = stores[5]!.redeem("sender", [{ id: pending }]); + try { + expect(Buffer.from(recovered.images[0]!.data, "base64").equals(image)).toBe(true); + } finally { + recovered.release(); + } + } finally { + for (const release of releases) release(); + for (const store of stores) store.close(); + } +}, 60_000); + +test("discarded in-flight source bytes stay reserved until decode settles, then capacity recovers", async () => { + const original = originalLargePng(); + const image = Buffer.concat([ + original.subarray(0, -12), + pngChunk("ruSt", Buffer.alloc(18.5 * 1024 * 1024)), + original.subarray(-12), + ]); + const stores = Array.from({ length: 5 }, () => new PromptImageUploadStore(alwaysConnected)); + const pendingStore = stores[4]!; + const releases: Array<() => void> = []; + try { + for (const store of stores.slice(0, 4)) { + const id = await stage(store, "sender", image); + releases.push(store.redeem("sender", [{ id }]).release); + } + const first = upload(pendingStore, "sender", image); + const second = upload(pendingStore, "sender", image); + const finishing = pendingStore.finish("sender", { id: first }); + pendingStore.discard("sender", { id: first }); + const third = upload(pendingStore, "sender", image); + const fourth = pendingStore.begin("sender", descriptor(image)); + const chunk = image.subarray(0, 96 * 1024).toString("base64"); + let capacityReached = false; + for (let sequence = 0; sequence < 64; sequence++) { + try { + pendingStore.append("sender", { id: fourth.id, sequence, data: chunk }); + } catch (error) { + expect(error).toMatchObject({ code: "busy" }); + capacityReached = true; + break; + } + } + expect(capacityReached).toBe(true); + await expect(finishing).rejects.toMatchObject({ code: "resource_gone" }); + for (const id of [second, third, fourth.id]) pendingStore.discard("sender", { id }); + for (const release of releases) release(); + const recovered = await stage(pendingStore, "sender", original); + const accepted = pendingStore.redeem("sender", [{ id: recovered }]); + try { + expect(Buffer.from(accepted.images[0]!.data, "base64").equals(original)).toBe(true); + } finally { + accepted.release(); + } + } finally { + for (const release of releases) release(); + for (const store of stores) store.close(); + } +}, 60_000); diff --git a/packages/coding-agent/test/session-manager/session-directory.test.ts b/packages/coding-agent/test/session-manager/session-directory.test.ts index cc87600a471..3ca443e4b99 100644 --- a/packages/coding-agent/test/session-manager/session-directory.test.ts +++ b/packages/coding-agent/test/session-manager/session-directory.test.ts @@ -1328,6 +1328,35 @@ describe("managed session write protocol", () => { await expect(fs.access(source)).rejects.toMatchObject({ code: "ENOENT" }); }); + it.each([ + "missing-binding", + "noncanonical-binding", + "unregistered-scope", + ] as const)("rejects cold reconciliation with %s without repairing storage", async boundary => { + const { cwd, sessionsRoot, scope } = await fixture(); + expect((await prepareManagedSessionScopeForWrite(scope)).kind).toBe("resolved"); + const source = path.join(legacyDirectory(sessionsRoot, cwd), "cold-authority.jsonl"); + const bytes = transcript("cold-authority", cwd); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, bytes); + const cold = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); + if (cold.kind !== "resolved") throw new Error(cold.message); + const binding = path.join(scope.directoryPath, MANAGED_SESSION_BINDING_FILE); + const original = await fs.readFile(binding, "utf8"); + if (boundary === "missing-binding") await fs.unlink(binding); + if (boundary === "noncanonical-binding") await fs.writeFile(binding, `${original}\n`); + const input = boundary === "unregistered-scope" ? { ...cold.scope } : cold.scope; + await expect(reconcileManagedTombstones(input)).rejects.toThrow( + "The existing managed GC read authority could not be verified.", + ); + expect(await fs.readFile(source, "utf8")).toBe(bytes); + if (boundary === "missing-binding") await expect(fs.access(binding)).rejects.toMatchObject({ code: "ENOENT" }); + else + expect(await fs.readFile(binding, "utf8")).toBe( + boundary === "noncanonical-binding" ? `${original}\n` : original, + ); + }); + it("completes after a crash following transcript unlink with a durable retained artifact root", async () => { const { cwd, sessionsRoot, scope } = await fixture(); const source = path.join(legacyDirectory(sessionsRoot, cwd), "post-transcript-crash.jsonl"); @@ -2165,6 +2194,73 @@ describe("managed session write protocol", () => { lock.mockRestore(); } }); + it.each([ + "success", + "replacement-failure", + "destination-swap", + ] as const)("closes exact recertification stores on %s without replaying transcript deletion", async boundary => { + const { cwd, sessionsRoot, scope } = await fixture(); + const source = path.join(legacyDirectory(sessionsRoot, cwd), "recertification-boundary.jsonl"); + await fs.mkdir(path.dirname(source), { recursive: true }); + await fs.writeFile(source, transcript("recertification-boundary", cwd)); + const listed = listManagedCandidates(scope); + if (listed.kind !== "complete" || !listed.owned[0]) throw new Error("Missing candidate"); + expect(await deleteManagedSessionCandidate(scope, listed.owned[0])).toMatchObject({ kind: "deleted" }); + const tombstones = path.join(scope.directoryPath, ".gjc-managed-session-internal", "tombstones"); + const names = (await fs.readdir(tombstones)).filter(name => name.includes(".cleanup-completed-")); + expect(names).toHaveLength(1); + const receipt = path.join(tombstones, names[0]!); + const stale = JSON.parse(await fs.readFile(receipt, "utf8")) as { target: { identity: { sha256: string } } }; + stale.target.identity.sha256 = "0".repeat(64); + const staleBytes = `${JSON.stringify(stale)}\n`; + await fs.writeFile(receipt, staleBytes); + const original = managedSessionStorage.ManagedSessionDescendantStore.prototype.replaceExpected; + const originalClose = managedSessionStorage.ManagedSessionDescendantStore.prototype.close; + const closed: managedSessionStorage.ManagedSessionDescendantStore[] = []; + let attemptedStore: managedSessionStorage.ManagedSessionDescendantStore | undefined; + const close = vi.spyOn(managedSessionStorage.ManagedSessionDescendantStore.prototype, "close"); + close.mockImplementation(function (this: managedSessionStorage.ManagedSessionDescendantStore) { + closed.push(this); + originalClose.call(this); + }); + let attempts = 0; + const replace = vi.spyOn(managedSessionStorage.ManagedSessionDescendantStore.prototype, "replaceExpected"); + replace.mockImplementation(function ( + this: managedSessionStorage.ManagedSessionDescendantStore, + relative, + bytes, + expected, + ) { + attempts += 1; + attemptedStore = this; + if (boundary === "replacement-failure") throw new Error("test_exact_replacement_failed"); + if (boundary === "destination-swap") { + syncFs.renameSync(receipt, `${receipt}.retained`); + syncFs.copyFileSync(`${receipt}.retained`, receipt); + } + return original.call(this, relative, bytes, expected); + }); + try { + const cold = resolveManagedScope({ cwd, agentDir: path.dirname(sessionsRoot), sessionsRoot }); + if (cold.kind !== "resolved") throw new Error(cold.message); + expect((await prepareManagedSessionScopeForWrite(cold.scope)).kind).toBe( + boundary === "success" ? "resolved" : "error", + ); + expect(attempts).toBe(1); + expect(closed.filter(store => store === attemptedStore)).toHaveLength(1); + const observed = await fs.readFile(receipt, "utf8"); + if (boundary === "success") { + const record = JSON.parse(observed) as { target: { identity: { sha256: string } } }; + expect(record.target.identity.sha256).toBe(listed.owned[0].identity.sha256); + } else expect(observed).toBe(staleBytes); + await expect(fs.access(source)).rejects.toMatchObject({ code: "ENOENT" }); + if (boundary === "destination-swap") expect(await fs.readFile(`${receipt}.retained`, "utf8")).toBe(staleBytes); + } finally { + replace.mockRestore(); + close.mockRestore(); + } + }); + it("still acquires the lock when a completed cleanup receipt no longer binds its target identity", async () => { const { cwd, sessionsRoot, scope } = await fixture(); const legacy = legacyDirectory(sessionsRoot, cwd); diff --git a/packages/coding-agent/test/session-manager/session-id.test.ts b/packages/coding-agent/test/session-manager/session-id.test.ts index 9f387c13a4b..8d1ee624844 100644 --- a/packages/coding-agent/test/session-manager/session-id.test.ts +++ b/packages/coding-agent/test/session-manager/session-id.test.ts @@ -96,6 +96,47 @@ describe("SessionManager session ids", () => { expect(session.getHeader()).not.toHaveProperty("starred"); }); + it("preserves artifact IDs in independent fork directories across reopen and later writes", async () => { + using tempDir = TempDir.createSync("@pi-session-fork-artifact-independence-"); + const destination = SessionManager.managedDestination(tempDir.path(), tempDir.path()); + const session = SessionManager.create(tempDir.path(), destination); + let parent: SessionManager | undefined; + let child: SessionManager | undefined; + try { + const payload = "x".repeat(2 * 1024 * 1024); + const artifactId = await session.saveArtifact(payload, "fork-reference"); + if (!artifactId) throw new Error("Expected an actual saved artifact"); + await session.ensureOnDisk(); + const forked = await session.fork(); + if (!forked) throw new Error("Expected a persistent fork"); + const forkArtifact = await session.getArtifactPath(artifactId); + if (!forkArtifact) throw new Error("Fork lost the saved artifact ID"); + expect(await Bun.file(forkArtifact).text()).toBe(payload); + await session.close(); + parent = await SessionManager.open(forked.oldSessionFile, destination); + child = await SessionManager.open(forked.newSessionFile, destination); + const parentArtifact = await parent.getArtifactPath(artifactId); + const childArtifact = await child.getArtifactPath(artifactId); + if (!parentArtifact || !childArtifact) throw new Error("Reopen lost a fork artifact ID"); + expect(parentArtifact).not.toBe(childArtifact); + expect(await Bun.file(parentArtifact).text()).toBe(payload); + expect(await Bun.file(childArtifact).text()).toBe(payload); + const parentId = await parent.saveArtifact("parent-only", "independent"); + const childId = await child.saveArtifact("child-only", "independent"); + if (!parentId || !childId) throw new Error("Expected independent artifact writes"); + const parentWrite = await parent.getArtifactPath(parentId); + const childWrite = await child.getArtifactPath(childId); + if (!parentWrite || !childWrite) throw new Error("Independent artifact paths unavailable"); + expect(parentWrite).not.toBe(childWrite); + expect(await Bun.file(parentWrite).text()).toBe("parent-only"); + expect(await Bun.file(childWrite).text()).toBe("child-only"); + } finally { + await session.close().catch(() => {}); + await parent?.close().catch(() => {}); + await child?.close().catch(() => {}); + } + }); + it("rolls back fork identity before publishing a transcript when artifact import fails", async () => { using tempDir = TempDir.createSync("@pi-session-fork-rollback-"); const destination = SessionManager.managedDestination(tempDir.path(), tempDir.path()); diff --git a/packages/coding-agent/test/task/no-session-output-refs.test.ts b/packages/coding-agent/test/task/no-session-output-refs.test.ts index d86ecdfeeae..7f2d98ec493 100644 --- a/packages/coding-agent/test/task/no-session-output-refs.test.ts +++ b/packages/coding-agent/test/task/no-session-output-refs.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -128,13 +128,25 @@ function createYieldingSession(output: string): AgentSession { } type TestToolSession = ToolSession & { disposeSession: () => Promise }; +let fixtureAuthStorage: AuthStorage | undefined; +let fixtureModelRoot: string | undefined; +const fixtureRegistries = new Set(); function createSession(sessionFile: string | null, sessionId = "test-in-memory-session"): TestToolSession { + if (!fixtureAuthStorage || !fixtureModelRoot) throw new Error("Parent model fixture is not initialized"); + const settings = Settings.isolated(); + const modelRegistry = new ModelRegistry(fixtureAuthStorage, path.join(fixtureModelRoot, "models.yml"), settings, { + agentDir: fixtureModelRoot, + automaticRefresh: false, + }); + fixtureRegistries.add(modelRegistry); const cleanups = new Set<() => Promise | void>(); return { cwd: "/tmp", hasUI: false, - settings: Settings.isolated(), + settings, + authStorage: fixtureAuthStorage, + modelRegistry, getSessionFile: () => sessionFile, getSessionId: () => sessionId, getArtifactsDir: () => (sessionFile ? sessionFile.slice(0, -6) : null), @@ -183,10 +195,21 @@ async function runDetachedTask( } describe("task no-session output refs", () => { - afterEach(() => { + beforeEach(async () => { + fixtureModelRoot = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-task-model-fixture-")); + fixtureAuthStorage = await AuthStorage.create(":memory:"); + fixtureAuthStorage.setRuntimeApiKey("anthropic", "test-key"); + }); + afterEach(async () => { AsyncJobManager.resetForTests(); InternalUrlRouter.resetForTests(); vi.restoreAllMocks(); + for (const registry of fixtureRegistries) registry.dispose(); + fixtureRegistries.clear(); + fixtureAuthStorage?.close(); + fixtureAuthStorage = undefined; + if (fixtureModelRoot) await fs.rm(fixtureModelRoot, { recursive: true, force: true }); + fixtureModelRoot = undefined; }); it("advertises durable agent:// output refs for in-memory parents and keeps them readable", async () => { @@ -1060,44 +1083,48 @@ describe("task no-session output refs", () => { vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue( createSessionResult(createYieldingSession("output that must remain durable")), ); - vi.spyOn(fs, "mkdtemp").mockRejectedValueOnce(new Error("EACCES: permission denied")); - const session = createSession(null, `alloc-fail-${Snowflake.next()}`); - const tool = await TaskTool.create(session); + vi.spyOn(fs, "mkdtemp").mockRejectedValueOnce(new Error("EACCES: permission denied")); const manager = new AsyncJobManager({ onJobComplete: async () => {} }); - AsyncJobManager.setInstance(manager); - const execute = async () => { - const started = await tool.execute("tool-call", { - agent: "executor", - tasks: [{ id: "NoSession", description: "produce output", assignment: "Return a result." }], - } as TaskParams); - const jobId = started.details?.async?.jobId; - if (!jobId) throw new Error("Expected detached task job id"); - await manager.waitForAll(); - return manager.getJob(jobId)?.resultText ?? ""; - }; - - const failedText = await execute(); - expect(failedText).toContain("Task completed; output artifact unavailable."); - expect(matchAgentOutputId(failedText, "NoSession")).toBeNull(); - expect(session.getArtifactsDir?.()).toBeNull(); + try { + const tool = await TaskTool.create(session); + AsyncJobManager.setInstance(manager); + const execute = async () => { + const started = await tool.execute("tool-call", { + agent: "executor", + tasks: [{ id: "NoSession", description: "produce output", assignment: "Return a result." }], + } as TaskParams); + const jobId = started.details?.async?.jobId; + if (!jobId) throw new Error("Expected detached task job id"); + await manager.waitForAll(); + return manager.getJob(jobId)?.resultText ?? ""; + }; - const record = manager.getSubagentRecords()[0]; - expect(record?.resumable).toBe(true); - const resumed = manager.resumeSubagent(record!.subagentId, undefined, "continue"); - expect(resumed.ok).toBe(true); - await manager.waitForAll(); - const resumedText = manager.getJob(resumed.jobId!)?.resultText ?? ""; - expect(resumedText).toContain("Task completed; output artifact unavailable."); - expect(matchAgentOutputId(resumedText, record!.subagentId)).toBeNull(); - expect(session.getArtifactsDir?.()).toBeNull(); + const failedText = await execute(); + expect(failedText).toContain("Task completed; output artifact unavailable."); + expect(matchAgentOutputId(failedText, "NoSession")).toBeNull(); + expect(session.getArtifactsDir?.()).toBeNull(); - const retriedText = await execute(); - expect(matchAgentOutputId(retriedText, "NoSession")).toBeTruthy(); - const artifactsDir = session.getArtifactsDir?.(); - expect(artifactsDir).toBeTruthy(); - await manager.dispose({ timeoutMs: 100 }); - await session.disposeSession(); - expect(await pathExists(artifactsDir!)).toBe(false); + const record = manager.getSubagentRecords()[0]; + expect(record?.resumable).toBe(true); + const resumed = manager.resumeSubagent(record!.subagentId, undefined, "continue"); + expect(resumed.ok).toBe(true); + await manager.waitForAll(); + const resumedText = manager.getJob(resumed.jobId!)?.resultText ?? ""; + expect(resumedText).toContain("Task completed; output artifact unavailable."); + expect(matchAgentOutputId(resumedText, record!.subagentId)).toBeNull(); + expect(session.getArtifactsDir?.()).toBeNull(); + + const retriedText = await execute(); + expect(matchAgentOutputId(retriedText, "NoSession")).toBeTruthy(); + const artifactsDir = session.getArtifactsDir?.(); + expect(artifactsDir).toBeTruthy(); + await manager.dispose({ timeoutMs: 100 }); + await session.disposeSession(); + expect(await pathExists(artifactsDir!)).toBe(false); + } finally { + await manager.dispose({ timeoutMs: 100 }); + await session.disposeSession(); + } }); }); diff --git a/scripts/telegram-daemon-generation-manifest.json b/scripts/telegram-daemon-generation-manifest.json index d8ca068988b..8c6967bf6ef 100644 --- a/scripts/telegram-daemon-generation-manifest.json +++ b/scripts/telegram-daemon-generation-manifest.json @@ -526,7 +526,7 @@ "telegram:packages/coding-agent/src/sdk/bus/daemon-paths.ts:HEARTBEAT_TTL_MS": "62255b5467995d21d3f929c863278ca3e815102001c51ca6d66840e1522ff990", "telegram:packages/coding-agent/src/sdk/bus/daemon-paths.ts:daemonPaths": "1bd6ae51096fedb95d47149b977f8a40e7f814a774ecfce21e027aac268b0379", "telegram:packages/coding-agent/src/sdk/bus/index.ts:buildIdentity": "246ad10dd6341037a20379a8544736039584d36482f6b2d44e3054f5e7f87724", - "telegram:packages/coding-agent/src/sdk/bus/index.ts:createNotificationsExtension": "f9bb3a299d0eba6b658d9d6e27abfdba4c21dce40deb982f013d11f356e31a08", + "telegram:packages/coding-agent/src/sdk/bus/index.ts:createNotificationsExtension": "f9c7bde3c219713378ca581381740861958a779d9dd32aec454ba67b6ca23434", "telegram:packages/coding-agent/src/sdk/bus/notification-service.ts:DaemonTransitionLock": "0fb018a6384bff312aab0345012936f7e0609e4691c841919426ad3d75841dcb", "telegram:packages/coding-agent/src/sdk/bus/notification-service.ts:NATIVE_PATH_IDENTITY_CONTRACT_VERSION": "ec669ef396909ce429e08ce4fa9a78b5f0106d8cedf967e634c6ae6974830b8a", "telegram:packages/coding-agent/src/sdk/bus/notification-service.ts:acquireDaemonTransitionLock": "0115500fcb5c5797008d607bd69694579c5b372420310f265d33a4759364decc", @@ -545,7 +545,7 @@ "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:ownerPidFromOwnerId": "46691373b2bee01f28f3817a6aa6a7efffe880c2cea337c89155582c98d952bf", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:runDaemonInternal": "e1cc26b8c8d077becac08373feb4f1889e3ccc9e375190b65128d941593c82dc", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:runDaemonSmoke": "6f085a667aa5c83de46d2d8945fb845c355fcbb43c46872342a44489203a5830", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:DAEMON_GENERATION": "eeaf768331e1e5b747bde71541fbf4f842e840d803a5a2ab76b604a8997b63e6", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:DAEMON_GENERATION": "1465e3922fe221eddbb50c2ec746e5a6da45f57f23fbb3b9d9eb8a99686a6c09", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:NOTIFICATION_PROTOCOL_VERSION": "b99289f651fedcf020d28dbaf6f07dd37e7e4a5f6dc1f5118b872112325f1e81", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-control.ts:DaemonProcessReference": "c3d13e3670a6245a1250c4ebfcd80a36dd8fc96c67ab64d9f979182bd117bc4e", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-control.ts:TelegramDaemonController": "a36dcfa7182037e1394e85ffb099296227cd68ccab2dc3349a10b2012336d488",