From 9858aaab84450c1072794afd434279fb4006245b Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 20 Aug 2026 17:57:23 -0700 Subject: [PATCH] fix(cli-bridge): state which call can add a turn after reconstruction --- .changeset/cli-bridge-continuation-honesty.md | 7 ++ packages/agent-provider-cli-bridge/README.md | 25 ++++- .../src/index.test.ts | 65 ++++++++++++ .../src/interaction-response.test.ts | 3 + .../src/native-continuation.test.ts | 4 +- .../src/retained-environment.ts | 26 +++-- .../tests/cli-bridge.integration.test.ts | 98 +++++++++++++++++++ 7 files changed, 215 insertions(+), 13 deletions(-) create mode 100644 .changeset/cli-bridge-continuation-honesty.md diff --git a/.changeset/cli-bridge-continuation-honesty.md b/.changeset/cli-bridge-continuation-honesty.md new file mode 100644 index 0000000..7212ea7 --- /dev/null +++ b/.changeset/cli-bridge-continuation-honesty.md @@ -0,0 +1,7 @@ +--- +"@tangle-network/agent-provider-cli-bridge": patch +--- + +State which call can add a turn to a reconstructed environment. +The README carries the two-tier continuation rule, one refusal replaces three near-copies of the same rule, and the message names `session.continueNative` and the capability it needs. +The contract test covers the `already_resolved_different`, `already_resolved_same`, and `cancelled` interaction acknowledgements against the real Bridge. diff --git a/packages/agent-provider-cli-bridge/README.md b/packages/agent-provider-cli-bridge/README.md index e850778..c022a26 100644 --- a/packages/agent-provider-cli-bridge/README.md +++ b/packages/agent-provider-cli-bridge/README.md @@ -47,6 +47,25 @@ The provider rejects any coordinate or digest mismatch instead of attaching to a If the caller crashes before saving `controlRef`, call `provider.lookupRun()` with the planned run coordinates. The lookup returns the server-issued digest only when all five planned coordinates match the retained run. +## Which call adds a turn + +Continuation has two tiers, and the tier depends on whether the environment object came from `create()` or from `get()`. + +In the process that created the environment, `environment.dispatch()`, `environment.stream()`, and `session.prompt()` all add a turn. +Passing the same `sessionId` continues the same CLI conversation. + +After `provider.get(environmentId)` reconstructs the environment, only `session.continueNative(request, { turn })` can add a turn. +It requires the `nativeContinuation` capability, which the provider publishes only for the Pi harness today, and only when the running Bridge proves the same contract. +The reconstructed environment deliberately refuses every other way to start work: `environment.stream()` and `environment.dispatch()` throw "a reconstructed cli-bridge environment can only control an existing run", and `session.prompt()` throws "a reconstructed cli-bridge session cannot start another turn". +A reconstructed environment for any other harness therefore controls its existing runs (status, cursor replay, result, cancellation, and interaction responses) but adds no turn. +Restart-safe continuation for those harnesses needs native sessions in the Bridge server itself, which the Bridge does not have yet; it is tracked in the `drewstone/cli-bridge` repository and cannot be reached from this adapter. + +The capability document keeps `sessions.continue: true` on a reconstructed environment because the Agent Interface couples that flag to `retainedControl`: a document that reports `sessions.continue: false` while keeping retained run control fails `AgentEnvironmentCapabilitiesSchema` with "retained control requires exact run, result, event, cancellation, replay, detach, turn, and session identity together". +`nativeContinuation` is the flag that states whether a reconstructed environment can add a turn: present means `continueNative` works, absent means no call on that environment starts one. + +A runtime caller continues a retained run with `RetainedRunHandle.continueNative`, not with `startRetainedRunInEnvironment`. +`startRetainedRunInEnvironment` requires `provider.list` for its ownership proof, and this provider exposes no `list`; it then calls `environment.dispatch()`, which a reconstructed environment refuses. + The bridge model is selected from run data in this order: the turn, the provider default, or the profile's `harness` plus `model.default`. Execution fails before network use when none is present. @@ -59,7 +78,7 @@ Retained native sessions currently require `kind: 'host'`. The provider rejects a sandbox default before it creates the retained session; use one-shot execution for `kind: 'sandbox'`. The Bridge currently refuses `netJail` with `kind: 'sandbox'`; use the sandbox's own egress policy. -Passing the same `sessionId` on later turns continues the same CLI conversation. +Passing the same `sessionId` on later turns continues the same CLI conversation while the creating process still holds the environment. `executionId` gives a turn stable bridge identity, and `lastEventId` reattaches after a reader failure. `dispatch()` starts a bridge-owned durable run and returns after detaching its HTTP reader. The returned `AgentSession` exposes status, cursor-based event replay, the terminal result, continuation, and cancellation. @@ -79,7 +98,7 @@ Replay and result reads fail loudly after cli-bridge's configured replay retenti Stopping a `session.events()` reader detaches only that replay observer. Stopping a direct `environment.stream()` reader or destroying the environment cancels its active bridge runs and waits for terminal confirmation. -Pi retained sessions also expose typed permission interactions. +Pi retained sessions also expose typed permission interactions and native continuation. The provider stores the selected harness, exact model route, and canonical create digest inside its opaque environment identifier. This identifier lets `provider.get()` reconstruct profile-selected routes after process death without a cache or repeated configuration. The create digest prevents an altered profile or workspace from reusing the previous retained identity. @@ -96,6 +115,8 @@ The environment and session then expose `respondToInteraction()` for exact respo Each command carries its run, session, execution, request digest, and stable operation identifier. Native turns require caller-stable `turnId` and `executionId` values so an ambiguous admission cannot create another run. The bridge records repeated operations and rejects a different answer for an existing operation. +Repeating one operation identifier returns its stored acknowledgement; a new operation identifier for an interaction that is already resolved returns `already_resolved_same` when the answer matches and `already_resolved_different` when it does not, and answering an interaction that a run cancellation closed returns `cancelled`. +The `expired` acknowledgement stays in the contract, but the Bridge never emits it, so only the unit contradiction table covers it. The provider reports an unconfirmed network result as retryable and never reports it as accepted. Native replay reads `/v1/runs/:runId/events` and validates each canonical envelope through Agent Interface before exposing it. When a turn supplies `interactions`, the provider carries its exact map, including an explicit `{}`; an omitted map remains omitted. diff --git a/packages/agent-provider-cli-bridge/src/index.test.ts b/packages/agent-provider-cli-bridge/src/index.test.ts index 51c34aa..05d0a47 100644 --- a/packages/agent-provider-cli-bridge/src/index.test.ts +++ b/packages/agent-provider-cli-bridge/src/index.test.ts @@ -12,6 +12,7 @@ import { type AgentProfile, } from "@tangle-network/agent-interface"; import type { AgentEnvironment } from "@tangle-network/agent-interface/environment-provider"; +import { AgentEnvironmentCapabilitiesSchema } from "@tangle-network/agent-interface/environment-provider"; import { describe, expect, it } from "vitest"; import { createCliBridgeProvider, defaultCliBridgeCapabilities } from "./index.js"; import { cliBridgeEnvironmentId } from "./environment-identity.js"; @@ -803,6 +804,70 @@ describe("createCliBridgeProvider", () => { expect(called).toBe(false); }); + it.each([ + { harness: "codex" as const, addsTurn: false }, + { harness: "pi" as const, addsTurn: true }, + ])( + "publishes on a reconstructed $harness environment exactly which call can add a turn", + async ({ harness, addsTurn }) => { + const model = `${harness}/reconstruction-model`; + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + defaultModel: model, + fetch: async (url) => { + if (new URL(String(url)).pathname === "/v1/capabilities") { + return Response.json(defaultCliBridgeCapabilities(harness)); + } + throw new Error(`unexpected request to ${String(url)}`); + }, + }); + const created = await provider.create({ + idempotencyKey: "reconstruction-environment", + profile: { name: "worker", harness }, + }); + + const reconstructed = await provider.get!(created.id); + if (!reconstructed) throw new Error("the provider did not reconstruct the environment"); + const capabilities = reconstructed.capabilities; + if (!capabilities) throw new Error("the reconstructed environment published no capabilities"); + + // `nativeContinuation` is the flag that states whether a reconstructed + // environment can add a turn. + expect(capabilities.nativeContinuation).toEqual( + addsTurn ? { atomicBoundary: true, requestIdempotency: true } : undefined, + ); + // `sessions.continue` cannot carry that answer: the Agent Interface + // couples it to retained run control, which this environment keeps. + expect(capabilities.sessions.continue).toBe(true); + expect(capabilities.retainedControl).toBeDefined(); + expect( + AgentEnvironmentCapabilitiesSchema.safeParse({ + ...capabilities, + sessions: { ...capabilities.sessions, continue: false }, + }).success, + ).toBe(false); + + const session = reconstructed.session!("reconstruction-session", { + controlRef: { + runId: "reconstruction-run", + provider: "cli-bridge", + environmentId: reconstructed.id, + sessionId: "reconstruction-session", + executionId: "reconstruction-execution", + requestDigest: testDigest("reconstruction-run"), + }, + }); + await expect(reconstructed.dispatch!({ prompt: "add a turn" })).rejects.toThrow( + /cannot start a turn/, + ); + await expect(consume(reconstructed)).rejects.toThrow(/cannot start a turn/); + await expect(session.prompt!({ prompt: "add a turn" })).rejects.toThrow( + /cannot start a turn/, + ); + expect(typeof session.continueNative === "function").toBe(addsTurn); + }, + ); + it("returns null when an exact retained run does not exist", async () => { const provider = createCliBridgeProvider({ baseUrl: "http://bridge.local", diff --git a/packages/agent-provider-cli-bridge/src/interaction-response.test.ts b/packages/agent-provider-cli-bridge/src/interaction-response.test.ts index 1229064..3c4cc0f 100644 --- a/packages/agent-provider-cli-bridge/src/interaction-response.test.ts +++ b/packages/agent-provider-cli-bridge/src/interaction-response.test.ts @@ -186,7 +186,10 @@ describe("CLI Bridge interaction responses", () => { it.each([ [400, "invalid_response", false], + [409, "already_resolved_different", false], [409, "binding_mismatch", false], + [409, "cancelled", false], + [409, "expired", false], [429, "transport_failure", true], [502, "transport_failure", true], ] as const)("classifies HTTP %i %s as retryable=%s", async (status, acknowledgementStatus, retryable) => { diff --git a/packages/agent-provider-cli-bridge/src/native-continuation.test.ts b/packages/agent-provider-cli-bridge/src/native-continuation.test.ts index 6406bc6..f004430 100644 --- a/packages/agent-provider-cli-bridge/src/native-continuation.test.ts +++ b/packages/agent-provider-cli-bridge/src/native-continuation.test.ts @@ -260,14 +260,14 @@ describe("cli-bridge native continuation", () => { expect(environment.creation).toBe("created"); expect(reconstructedEnvironment.creation).toBeUndefined(); await expect(reconstructedEnvironment.dispatch!({ prompt: "must not dispatch" })).rejects.toThrow( - /cannot dispatch new work/, + /cannot start a turn through environment\.dispatch\(\)/, ); const reconstructed = reconstructedEnvironment.session!(initialControlRef.sessionId, { controlRef: initialControlRef, }); expect(reconstructed.prompt).toBeDefined(); await expect(reconstructed.prompt!({ prompt: "must not dispatch" })).rejects.toThrow( - /cannot start another turn/, + /cannot start a turn through session\.prompt\(\)/, ); await expect(reconstructed.contextBoundary!()).resolves.toEqual(boundary); diff --git a/packages/agent-provider-cli-bridge/src/retained-environment.ts b/packages/agent-provider-cli-bridge/src/retained-environment.ts index aa93422..f3e901f 100644 --- a/packages/agent-provider-cli-bridge/src/retained-environment.ts +++ b/packages/agent-provider-cli-bridge/src/retained-environment.ts @@ -84,6 +84,20 @@ export interface CreateCliBridgeEnvironmentArgs { readonly selectedModel?: string; } +/** + * One refusal for every call that would add a turn to a reconstructed + * environment. `provider.get()` rebuilds control of existing runs from the + * opaque environment id alone, so it holds no create request that a new native + * session could reuse. Only `session.continueNative` advances a retained run + * after reconstruction, and only where the Bridge proves `nativeContinuation`. + */ +function assertCliBridgeTurnAllowed(allowed: boolean, surface: string): void { + if (allowed) return; + throw new Error( + `a reconstructed cli-bridge environment cannot start a turn through ${surface}; use session.continueNative, which requires the nativeContinuation capability`, + ); +} + export function createCliBridgeEnvironment( args: CreateCliBridgeEnvironmentArgs, ): AgentEnvironment { @@ -112,9 +126,7 @@ export function createCliBridgeEnvironment( turn: AgentTurnInput, ): AsyncIterable { if (destroyed) throw new Error("cli-bridge environment is destroyed"); - if (!args.allowDispatch) { - throw new Error("a reconstructed cli-bridge environment can only control an existing run"); - } + assertCliBridgeTurnAllowed(args.allowDispatch, "environment.stream()"); if ( args.selectedModel !== undefined && args.capabilities.retainedControl !== undefined @@ -168,9 +180,7 @@ export function createCliBridgeEnvironment( stream, async dispatch(turn) { if (destroyed) throw new Error("cli-bridge environment is destroyed"); - if (!args.allowDispatch) { - throw new Error("a reconstructed cli-bridge environment cannot dispatch new work"); - } + assertCliBridgeTurnAllowed(args.allowDispatch, "environment.dispatch()"); if ( args.selectedModel !== undefined && args.capabilities.retainedControl !== undefined @@ -419,9 +429,7 @@ function createCliBridgeSession(args: CreateCliBridgeSessionArgs): AgentSession return result; }, async prompt(input: AgentTurnInput): Promise { - if (!args.allowPrompt) { - throw new Error("a reconstructed cli-bridge session cannot start another turn"); - } + assertCliBridgeTurnAllowed(args.allowPrompt, "session.prompt()"); if (input.sessionId && input.sessionId !== args.id) { throw new Error( `cli-bridge session "${args.id}" cannot prompt session "${input.sessionId}"`, diff --git a/packages/agent-provider-cli-bridge/tests/cli-bridge.integration.test.ts b/packages/agent-provider-cli-bridge/tests/cli-bridge.integration.test.ts index 02f556d..cb23f72 100644 --- a/packages/agent-provider-cli-bridge/tests/cli-bridge.integration.test.ts +++ b/packages/agent-provider-cli-bridge/tests/cli-bridge.integration.test.ts @@ -245,6 +245,40 @@ describeActualBridge("actual cli-bridge native interaction contract", () => { "durable interaction response request", ).toBe(true); + // The same interaction, answered again under a new operation identifier. + // The Bridge compares the response digest, not the operation, so a + // different answer is a conflict and an identical answer is the same + // resolution. + const resolvedAgain = async ( + operationId: string, + response: InteractionResponse, + ): Promise => { + const binding = command.binding; + return environment.respondToInteraction!({ + operationId, + binding, + response, + commandDigest: interactionResponseCommandDigest({ binding, response }), + }); + }; + await expect( + resolvedAgain("sdk-cli-bridge-integration-conflicting-response", { + id: interaction.id, + outcome: "accepted", + data: { grant: ["deny"] }, + }), + ).resolves.toMatchObject({ + status: "already_resolved_different", + operationId: "sdk-cli-bridge-integration-conflicting-response", + retryable: false, + }); + await expect( + resolvedAgain("sdk-cli-bridge-integration-identical-response", command.response), + ).resolves.toMatchObject({ + status: "already_resolved_same", + operationId: "sdk-cli-bridge-integration-identical-response", + }); + const replayed: AgentEnvironmentEvent[] = []; for await (const event of session.events({ since: "0" })) replayed.push(event); expect(replayed.map((event) => event.id)).toEqual(firstEvents.map((event) => event.id)); @@ -304,6 +338,70 @@ describeActualBridge("actual cli-bridge native interaction contract", () => { } }, 30_000); + it("answers a cancelled interaction with the cancelled acknowledgement", async () => { + const bridge = await startActualBridge(); + try { + const sessionId = "sdk-cli-bridge-cancelled-session"; + const provider = createCliBridgeProvider({ + baseUrl: bridge.baseUrl, + defaultModel: "pi/tangle-router/sdk-integration-model", + }); + const environment = await provider.create({ + idempotencyKey: "sdk-cli-bridge-cancelled-environment", + profile: { name: "integration", harness: "pi" }, + workspace: { cwd: bridge.projectDir }, + }); + const reference = await environment.dispatch!({ + prompt: "pause on a permission this run never answers", + sessionId, + turnId: "sdk-cli-bridge-cancelled-turn", + executionId: "sdk-cli-bridge-cancelled-run", + interactions: { permission: true }, + }); + const controlRef = reference.controlRef as AgentExactRunControlRef | undefined; + if (!controlRef) throw new Error("the actual Bridge did not return an exact native control reference"); + const session = environment.session!(sessionId, { controlRef }); + + let interaction: InteractionRequest | undefined; + for await (const event of session.events({ since: "0" })) { + if (event.normalized?.type !== "interaction") continue; + interaction = event.normalized.request; + break; + } + if (!interaction) throw new Error("the actual Bridge did not pause on a native interaction"); + + // The permission is still outstanding, so cancelling the run closes it + // before any response can take effect. + await session.cancel(); + await expect(session.status()).resolves.toBe("cancelled"); + + const binding = { ...interaction.binding, requestDigest: interaction.requestDigest }; + const response: InteractionResponse = { + id: interaction.id, + outcome: "accepted", + data: { grant: ["allow_once"] }, + }; + await expect( + environment.respondToInteraction!({ + operationId: "sdk-cli-bridge-cancelled-response", + binding, + response, + commandDigest: interactionResponseCommandDigest({ binding, response }), + }), + ).resolves.toMatchObject({ + status: "cancelled", + operationId: "sdk-cli-bridge-cancelled-response", + retryable: false, + }); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nActual Bridge output:\n${bridge.logs()}`, + ); + } finally { + await bridge.stop(); + } + }, 30_000); + it("continues one live Pi session after a provider restart and replays the exact request", async () => { const bridge = await startActualBridge(); const requests: Array<{